From 65a97c099634691fed1c2ee619b74b5cf0254739 Mon Sep 17 00:00:00 2001 From: Aere Network Date: Thu, 10 Sep 2026 22:36:08 +0300 Subject: [PATCH] Aere Network: post-quantum certificate anchor for QBFT Puts a 32-byte digest of the validator certificate into vanityData, which is already covered by the block hash, so that the hash commits to the certificate. The certificate itself still rides outside the hash and is pinned by the digest. Apache License 2.0, section 4(b): the files this patch modifies are the work of Hyperledger Besu and carry its copyright notice. This patch changes them. The modifications are by contributors to the Aere Network and are offered under the same Apache License 2.0; the upstream copyright headers are left as found. The slhdsa/ package under consensus/common is derived from Bouncy Castle 1.80 and stays under the Bouncy Castle licence; its notice is carried alongside (LICENSE-BouncyCastle.txt). Updated 2026-09-05: the v2, scheme-tagged hybrid certificate (Falcon-512 and SLH-DSA-SHA2-128s seals, live on chain 2800 since block 17,047,600), the in-tree SLH-DSA engine on the JDK digest, the seal-store repair (a commit heard before the round held its proposal, or a late commit, was dropped from the store; the round-0 proposer of an anchor then refused to propose), and the anchor interval schedule (aere.pq.anchorIntervalSchedule, 17225968:128 on chain 2800). The post-quantum message layers (PROPOSAL, PREPARE, COMMIT, ROUND-CHANGE seals with per-type domains) are included with their emission and enforcement height switches; see anchor/README.md for what is armed on chain 2800. Regenerated 2026-09-10 from the production tree over the same file set, so that the public recipe carries the same Apache-2.0 section 4(b) modification notices that the production sources carry; measured the same day: a build from this recipe reproduces the fleet's consensus jars class for class. --- diff --git a/app/src/main/java/org/hyperledger/besu/cli/BesuCommand.java b/app/src/main/java/org/hyperledger/besu/cli/BesuCommand.java index 2767db2eb..df19c1dd2 100644 --- a/app/src/main/java/org/hyperledger/besu/cli/BesuCommand.java +++ b/app/src/main/java/org/hyperledger/besu/cli/BesuCommand.java @@ -11,6 +11,12 @@ * specific language governing permissions and limitations under the License. * * SPDX-License-Identifier: Apache-2.0 + * + * Modifications Copyright contributors to the Aere Network. + * + * This file was modified by contributors to the Aere Network, as required by section 4(b) of the + * Apache License 2.0. The copyright header above is the upstream one and is left exactly as it was + * found, as section 4(c) requires. The change itself is in patches/0003-aere-pq-anchor.patch. */ package org.hyperledger.besu.cli; @@ -41,6 +47,7 @@ import org.hyperledger.besu.cli.config.ProfilesCompletionCandidates; import org.hyperledger.besu.cli.custom.JsonRPCAllowlistHostsProperty; import org.hyperledger.besu.cli.error.BesuExecutionExceptionHandler; import org.hyperledger.besu.cli.error.BesuParameterExceptionHandler; +import org.hyperledger.besu.cli.options.AerePqEmergencyOptions; import org.hyperledger.besu.cli.options.ApiConfigurationOptions; import org.hyperledger.besu.cli.options.BalConfigurationOptions; import org.hyperledger.besu.cli.options.ChainPruningOptions; @@ -299,6 +306,13 @@ public class BesuCommand implements DefaultCommandValues, Runnable { private final ChainPruningOptions unstableChainPruningOptions = ChainPruningOptions.create(); private final QBFTOptions unstableQbftOptions = QBFTOptions.create(); + /** + * AERE OPTIUNI-URGENTA (2026-08-02): the three post-quantum emergency controls, so that standing + * the anchor down is a RESTART and not a rebuild. See {@link AerePqEmergencyOptions}. + */ + private final AerePqEmergencyOptions unstableAerePqEmergencyOptions = + AerePqEmergencyOptions.create(); + // stable CLI options final DataStorageOptions dataStorageOptions = DataStorageOptions.create(); private final EthstatsOptions ethstatsOptions = EthstatsOptions.create(); @@ -950,6 +964,13 @@ public class BesuCommand implements DefaultCommandValues, Runnable { logger.info("Starting Besu"); + // AERE OPTIUNI-URGENTA (2026-08-02): apply the post-quantum emergency controls FIRST, before + // anything that reads them. FalconSealSupport runs its guards in its constructor and + // PqAnchorConfig is resolved while the protocol schedule is assembled, both of which happen + // inside buildController() further down. This is also the earliest point at which logging is + // configured, so the banner lands where an operator will see it. + unstableAerePqEmergencyOptions.applyAndAnnounce(); + // set merge config on the basis of genesis config setMergeConfigOptions(); @@ -1243,6 +1264,9 @@ public class BesuCommand implements DefaultCommandValues, Runnable { .put("IPC Options", unstableIpcOptions) .put("Chain Data Pruning Options", unstableChainPruningOptions) .put("QBFT Options", unstableQbftOptions) + // AERE OPTIUNI-URGENTA: registered here so `besu --Xhelp` LISTS them. An emergency + // control nobody can find at three in the morning is not a control. + .put("AERE post-quantum emergency controls", unstableAerePqEmergencyOptions) .build(); UnstableOptionsSubCommand.createUnstableOptions(commandLine, unstableOptions); diff --git a/app/src/main/java/org/hyperledger/besu/cli/options/AerePqEmergencyOptions.java b/app/src/main/java/org/hyperledger/besu/cli/options/AerePqEmergencyOptions.java new file mode 100755 index 000000000..509c81f73 --- /dev/null +++ b/app/src/main/java/org/hyperledger/besu/cli/options/AerePqEmergencyOptions.java @@ -0,0 +1,244 @@ +/* + * Copyright contributors to Besu / AERE Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.cli.options; + +import org.hyperledger.besu.consensus.common.bft.FalconSealSupport; +import org.hyperledger.besu.consensus.common.bft.PqAnchorConfig; + +import java.util.ArrayList; +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import picocli.CommandLine; + +/** + * AERE OPTIUNI-URGENTA (2026-08-02): the three emergency controls for the post-quantum certificate + * anchor, as COMMAND-LINE OPTIONS. + * + *

The requirement, in one sentence: there has to be a way back that does not need a build. + * If the anchor misbehaves on the live chain at three in the morning, the person on the other end of + * the page has to be able to stand it down with a RESTART. Until this class existed the controls + * were real but reachable only as system properties and environment variables, which in practice + * means editing a systemd unit or a wrapper script on seven machines under time pressure, in a file + * whose syntax nobody remembers, with no {@code --help} to check against. Two of these options were + * already named in the javadoc of {@code PqAnchorConfig} as though they existed. They did not. + * + *

The three controls, and why exactly these three. + * + *

+ * + *

Every one of them shouts. A quiet way out is worse than no way out, because it will be + * set once in an emergency and found six months later on a fleet everybody believed was enforcing + * something. So each control that is IN FORCE costs one complete ERROR line at EVERY height, for as + * long as the node runs: see {@code PqEmergencyShoutRule} and {@code + * FalconSealSupport.shoutRegistryOverrideUnsafe}. Nothing is emitted when nothing is overridden, so + * a clean log is a measurable statement and not an absence of instrumentation. + * + *

How they take effect, and why through the properties. Each option writes the SAME system + * property the control has always read, before anything reads it. That is deliberate: it leaves + * exactly one place where each decision is made, so the command line cannot mean something subtly + * different from the environment variable, and the code that was measured under A8 and under the + * anchor work is the code still doing the deciding. Precedence is command line, then system + * property, then environment variable; which source won is written into the log so an operator never + * has to guess whether the flag took. + * + *

Deliberately LOCAL, not on-chain. A halted chain cannot deliver a height-scheduled + * configuration change. The only control that works when the chain is ALREADY STOPPED is one that + * lives in the node's own start-up and takes effect on restart, and restarting a quorum of nodes is + * the right price for a change of this weight. + */ +public class AerePqEmergencyOptions { + + private static final Logger LOG = LoggerFactory.getLogger(AerePqEmergencyOptions.class); + + /** The stable log code for the startup banner listing every override in force. */ + public static final String BANNER_CODE = "AERE-PQC-EMG-BANNER-01"; + + /** Default constructor. */ + private AerePqEmergencyOptions() {} + + /** + * Create a new instance. + * + * @return a new instance + */ + public static AerePqEmergencyOptions create() { + return new AerePqEmergencyOptions(); + } + + @CommandLine.Option( + names = {"--Xaere-pq-anchor-disarm"}, + description = + "AERE EMERGENCY. Switch the post-quantum certificate anchor rules OFF on this node. Takes " + + "effect on restart, needs no rebuild, and makes this node log one ERROR line per " + + "block for as long as it is set. Recovery setting only. (default: ${DEFAULT-VALUE})", + arity = "0..1", + fallbackValue = "true", + paramLabel = "", + hidden = true) + private Boolean anchorDisarm = null; + + @CommandLine.Option( + names = {"--Xaere-pq-anchor-min-seals-max"}, + description = + "AERE EMERGENCY. Cap the post-quantum seal threshold K at this value. It can only LOWER " + + "the scheduled threshold, never raise it. Use when the chain has stalled because " + + "proposers cannot gather K seals. Logs one ERROR line per block while it is " + + "actually lowering something. (default: ${DEFAULT-VALUE})", + paramLabel = "", + hidden = true) + private Integer minSealsMax = null; + + @CommandLine.Option( + names = {"--Xaere-pq-registry-mismatch-allow"}, + description = + "AERE EMERGENCY. Start and keep running even though this node's Falcon registry does not " + + "satisfy the binding genesis requires. The node then does NOT verify post-quantum " + + "certificates and says so at every height. Recovery setting only. (default: " + + "${DEFAULT-VALUE})", + arity = "0..1", + fallbackValue = "true", + paramLabel = "", + hidden = true) + private Boolean registryMismatchAllow = null; + + /** + * Apply whatever was given on the command line onto the system properties the controls read, and + * announce the resulting state. + * + *

Call this ONCE, early in startup and before the controller is built, because {@code + * PqAnchorConfig.fromSystemConfiguration()} and the {@code FalconSealSupport} constructor both + * read these properties while the protocol schedule is being assembled. + * + *

An out-of-range ceiling REFUSES TO START rather than being ignored. An emergency control that + * silently declines to do what the operator typed is the worst possible behaviour for a control + * used under pressure: the operator would believe the chain had been given a way out and would go + * looking somewhere else for why it had not moved. + * + * @throws IllegalArgumentException when an option value is out of range + */ + public void applyAndAnnounce() { + final List inForce = new ArrayList<>(); + + if (minSealsMax != null && minSealsMax < 0) { + throw new IllegalArgumentException( + "--Xaere-pq-anchor-min-seals-max must not be negative (got " + + minSealsMax + + "). Use 0 to require no post-quantum seals at all."); + } + + if (anchorDisarm != null) { + System.setProperty(PqAnchorConfig.PROPERTY_DISABLE, anchorDisarm.toString()); + } + if (minSealsMax != null) { + System.setProperty(PqAnchorConfig.PROPERTY_MIN_SEALS_CEILING, minSealsMax.toString()); + } + if (registryMismatchAllow != null) { + System.setProperty( + FalconSealSupport.PROPERTY_REGISTRY_MISMATCH_ALLOW, registryMismatchAllow.toString()); + } + + // Read back from the properties, NOT from the fields. What is announced has to be what the + // controls will actually see, including the case where the operator set the environment + // variable and nothing on the command line. + if (Boolean.parseBoolean( + effective(PqAnchorConfig.PROPERTY_DISABLE, "AERE_PQ_ANCHOR_DISABLE"))) { + inForce.add( + "ANCHOR DISARMED (both certificate-anchor rules off) via " + + origin(anchorDisarm, PqAnchorConfig.PROPERTY_DISABLE, "AERE_PQ_ANCHOR_DISABLE")); + } + final String ceiling = + effective(PqAnchorConfig.PROPERTY_MIN_SEALS_CEILING, "AERE_PQ_ANCHOR_MIN_SEALS_MAX"); + if (ceiling != null) { + inForce.add( + "SEAL THRESHOLD CAPPED at K<=" + + ceiling + + " (lowering only, never raising) via " + + origin( + minSealsMax, + PqAnchorConfig.PROPERTY_MIN_SEALS_CEILING, + "AERE_PQ_ANCHOR_MIN_SEALS_MAX")); + } + if (Boolean.parseBoolean( + effective( + FalconSealSupport.PROPERTY_REGISTRY_MISMATCH_ALLOW, + FalconSealSupport.ENV_REGISTRY_MISMATCH_ALLOW))) { + inForce.add( + "REGISTRY BINDING BYPASSED (this node may run without verifying post-quantum " + + "certificates) via " + + origin( + registryMismatchAllow, + FalconSealSupport.PROPERTY_REGISTRY_MISMATCH_ALLOW, + FalconSealSupport.ENV_REGISTRY_MISMATCH_ALLOW)); + } + + if (inForce.isEmpty()) { + // Say NOTHING. The silence is a measurement: a node with no AERE-PQC-EMG line in its log is + // running with no emergency control in force, and that has to be checkable by grep. + return; + } + + LOG.error( + "AERE PQC EMERGENCY [{}]: this node is starting with {} POST-QUANTUM SAFETY CONTROL(S) " + + "OVERRIDDEN: {}. These are RECOVERY settings. They reduce what this node checks, they " + + "are local to this node and bound to nothing, and a fleet running on them is not " + + "running the scheme it is documented as running. Each one in force writes a line at " + + "every block, on purpose. Remove them and restart as soon as the cause is fixed.", + BANNER_CODE, + inForce.size(), + String.join(" | ", inForce)); + } + + /** The value a control will actually see: system property first, then environment variable. */ + private static String effective(final String property, final String environmentVariable) { + final String fromProperty = System.getProperty(property); + if (fromProperty != null && !fromProperty.isBlank()) { + return fromProperty.trim(); + } + final String fromEnvironment = System.getenv(environmentVariable); + if (fromEnvironment != null && !fromEnvironment.isBlank()) { + return fromEnvironment.trim(); + } + return null; + } + + /** Where the value in force came from, so nobody has to guess whether the flag took. */ + private static String origin( + final Object fromCommandLine, final String property, final String environmentVariable) { + if (fromCommandLine != null) { + return "the COMMAND LINE"; + } + final String fromProperty = System.getProperty(property); + if (fromProperty != null && !fromProperty.isBlank()) { + return "system property " + property; + } + return "environment variable " + environmentVariable; + } +} diff --git a/app/src/main/java/org/hyperledger/besu/controller/QbftBesuControllerBuilder.java b/app/src/main/java/org/hyperledger/besu/controller/QbftBesuControllerBuilder.java index 7fbf58d0b..281add4c9 100644 --- a/app/src/main/java/org/hyperledger/besu/controller/QbftBesuControllerBuilder.java +++ b/app/src/main/java/org/hyperledger/besu/controller/QbftBesuControllerBuilder.java @@ -11,6 +11,12 @@ * specific language governing permissions and limitations under the License. * * SPDX-License-Identifier: Apache-2.0 + * + * Modifications Copyright contributors to the Aere Network. + * + * This file was modified by contributors to the Aere Network, as required by section 4(b) of the + * Apache License 2.0. The copyright header above is the upstream one and is left exactly as it was + * found, as section 4(c) requires. The change itself is in patches/0003-aere-pq-anchor.patch. */ package org.hyperledger.besu.controller; @@ -18,6 +24,7 @@ import static com.google.common.base.Preconditions.checkNotNull; import org.hyperledger.besu.config.BftConfigOptions; import org.hyperledger.besu.config.BftFork; +import org.hyperledger.besu.config.JsonGenesisConfigOptions; import org.hyperledger.besu.config.QbftConfigOptions; import org.hyperledger.besu.config.QbftFork; import org.hyperledger.besu.consensus.common.BftValidatorOverrides; @@ -33,7 +40,15 @@ import org.hyperledger.besu.consensus.common.bft.BftRoundExpiryTimeCalculator; import org.hyperledger.besu.consensus.common.bft.BlockTimer; import org.hyperledger.besu.consensus.common.bft.EthSynchronizerUpdater; import org.hyperledger.besu.consensus.common.bft.EventMultiplexer; +import org.hyperledger.besu.consensus.common.bft.FalconSealSupport; +import org.hyperledger.besu.consensus.common.bft.HybridSealSupport; import org.hyperledger.besu.consensus.common.bft.MessageTracker; +import org.hyperledger.besu.consensus.common.bft.PqAnchorConfig; +import org.hyperledger.besu.consensus.common.bft.PqAnchorSyncModeGuard; +import org.hyperledger.besu.consensus.common.bft.PqAnchorThresholdGuard; +import org.hyperledger.besu.consensus.common.bft.PqRegistryHash; +import org.hyperledger.besu.consensus.common.bft.PqSealCache; +import org.hyperledger.besu.consensus.common.bft.PqSignerRegistry; import org.hyperledger.besu.consensus.common.bft.RoundTimer; import org.hyperledger.besu.consensus.common.bft.UniqueMessageMulticaster; import org.hyperledger.besu.consensus.common.bft.blockcreation.BftMiningCoordinator; @@ -197,6 +212,283 @@ public class QbftBesuControllerBuilder extends BesuControllerBuilder { final SyncState syncState, final EthProtocolManager ethProtocolManager) { final MutableBlockchain blockchain = protocolContext.getBlockchain(); + + // AERE GARDA-ACTIVARE (2026-08-01): the CHAIN-RELATIVE half of the Falcon seal-attachment guard. + // + // FalconSealSupport is constructed eagerly while the protocol schedule is built, which is early + // enough to validate the configuration against ITSELF but too early to see a chain. That left a + // measured hole: attachmentArmed() only ever required blockNumber >= attachBlock, so a validator + // restarted with an activation height the chain had ALREADY PASSED started with no complaint and + // armed on its first commit, emitting Falcon-carrying Commits into a fleet that had not armed. + // + // This is the first point in startup where a chain head exists, and it is still before the + // network and the QBFT state machine are started, so an abort here is a clean refusal to start + // rather than a mid-flight halt. The predicate answers "does the header at this height carry at + // least one Falcon seal", which is the on-chain proof that the fleet legitimately armed and is + // what lets a crashed validator restart into a live activation instead of being locked out. + FalconSealSupport.instance() + .verifyAttachHeightAgainstChainHeadOrAbort( + blockchain.getChainHeadBlockNumber(), + n -> + blockchain + .getBlockHeader(n) + .map(h -> !qbftExtraDataCodec.decode(h).getFalconSeals().isEmpty()) + .orElse(false), + dataDirectory); + + // D-334 (2026-09-03): the HYBRID configuration (scheme schedule, registry, SLH-DSA key) is + // loaded HERE, at startup, and not on first use. Measured on mainnet 2800: a registry the + // container user could not read passed every configuration gate (the file existed) and blew up + // inside the QBFT state machine at every anchor parent on three armed validators; with a fourth + // node restarting the chain stood 125 s. A defect in this configuration must refuse to START, + // exactly like the Falcon key above; QbftRound.hybridExtrasOrEmpty is the second net. + HybridSealSupport.instance(); + + // AERE GARDA-CHEIE-MASINA (2026-08-07): the Falcon key loaded must be THIS machine's own. + // + // Measured on seven test nodes with a single variable changed: node 0 was handed index 1's key. + // It started cleanly, logged index 1 -- the very same line the node that REALLY is index 1 + // writes -- and the chain crossed the K=3 step without a pause, with zero rejected blocks and + // zero divergence, because the seals VERIFY. What was lost in silence: index 0 disappeared from + // the signer set and index 1 was counted twice. + // + // This is the first point in startup where the node knows its own validator address, and it is + // still before the network and the QBFT state machine, so a refusal here is a clean refusal to + // start. The guard is INERT for as long as there is no aere.falcon.key and no address-bound + // registry, so on 2800 as it stands today it cannot fire. + FalconSealSupport.instance() + .verifyLocalKeyBelongsToThisNodeOrAbort(Util.publicKeyToAddress(nodeKey.getPublicKey())); + + // AERE BLOCAJ-REPORNIRE (2026-08-05): activate the late anchor FROM THE CHAIN HEAD STATE, at + // startup, and not only when a block is imported. + // + // WHY, and this is a measured chain death, not a supposition. The full activation rehearsal, on + // a seven-node test network, found a CIRCULAR DEADLOCK nobody had been looking for: + // activateLateAnchor() was called ONLY from FalconSealValidationRule, that is only while + // validating an IMPORTED block. After a simultaneous restart of the whole fleet no block is + // imported, because nobody proposes. So lateActivated stays false, attachmentArmed() answers + // false, no seal is attached, no certificate reaches K, and nobody can propose. Seals come + // from Commits, Commits come from proposals, and proposals need seals. + // The node's exact message, verbatim from the run: "refusing to propose ... holds 0 valid + // eligible Falcon seal(s) ... threshold is 3", over "Attachment stays OFF (fail-safe)". + // With K=0 the chain heals itself. With K>0 it NEVER heals. + // + // A simultaneous restart of the whole fleet is not an exotic scenario: it is a power cut, a + // scheduled + // kernel update, or any procedure that starts the fleet all at once. + // + // The repair invents nothing and weakens no check: it does here, once, exactly what the import + // path already did, reading the same slot 0 of the same contract, out of the chain head state. + // activateLateAnchor stays idempotent and fail-closed: on a hash mismatch the registry stays + // EMPTY and the path is terminally failed, exactly as before. If the head state cannot be read, + // nothing changes and the import path remains the second chance. + // + // Deliberately placed before the registry guard and before the QBFT machine starts, so that a + // refusal here is still a clean refusal to start. + if (FalconSealSupport.instance().anchorAddress() != null) { + try { + final BlockHeader aereCap = blockchain.getChainHeadHeader(); + protocolContext + .getWorldStateArchive() + .get(aereCap.getStateRoot(), aereCap.getHash()) + .ifPresent( + ws -> { + final var cont = ws.get(Address.fromHexString(FalconSealSupport.instance().anchorAddress())); + if (cont == null) { + return; + } + final var slot0 = cont.getStorageValue(org.apache.tuweni.units.bigints.UInt256.ZERO); + if (slot0 == null || slot0.isZero()) { + return; + } + final boolean armat = + FalconSealSupport.instance() + .activateLateAnchor(slot0.toBytes().toUnprefixedHexString()); + LOG.info( + "AERE PQC: anchor activation at STARTUP from the chain head state {}: {}. " + + "Without it, a simultaneous fleet restart with K>0 deadlocks the chain " + + "for good (measured on a seven-node test network, 2026-08-05).", + aereCap.getNumber(), + armat ? "SUCCEEDED" : "failed, the import path remains the second chance"); + }); + } catch (final Exception e) { + LOG.warn( + "AERE PQC: the anchor activation at startup could not be done ({}); the import path " + + "remains the second chance, but after a fleet restart with K>0 that path never " + + "opens again.", + e.toString()); + } + + // AERE-D358 (2026-09-10, measured on validator 8 of chain 2800): the "import path" named above + // is FalconSealValidationRule, and on 2800 that rule is RETIRED from the anchor block + // (PqAnchorConfig.legacyFalconRuleRetirementBlock), so it never runs. The startup attempt + // above is therefore the ONLY attempt, and it is silent when the head world state is not yet + // readable at that instant: after a plain container restart at 15:41Z the manifest loaded as + // PENDING, no STARTUP line was logged at all, and the node stayed PENDING for 55 minutes - + // refusing every peer Commit and Prepare as "NO post-quantum seal", emitting nothing, timing + // out its proposer slot every rotation, and slowing the whole chain from 0.56 to 1.07 s/block. + // A second restart activated at once, which is exactly what a race looks like. + // + // The repair: while the late anchor is PENDING, retry the same read (same contract, same + // slot 0, same activateLateAnchor) on every block added to the chain, and stop retrying once + // it is activated or terminally failed. It invents nothing and weakens no check. + if (FalconSealSupport.instance().anchorAddress() != null + && FalconSealSupport.instance().lateAnchorPending()) { + final java.util.concurrent.atomic.AtomicLong aereD358Retries = + new java.util.concurrent.atomic.AtomicLong(); + blockchain.observeBlockAdded( + event -> { + final FalconSealSupport pqc = FalconSealSupport.instance(); + if (!pqc.lateAnchorPending()) { + return; + } + try { + final BlockHeader h = event.getBlock().getHeader(); + protocolContext + .getWorldStateArchive() + .get(h.getStateRoot(), h.getHash()) + .ifPresent( + ws -> { + final var cont = + ws.get(Address.fromHexString(pqc.anchorAddress())); + if (cont == null) { + return; + } + final var slot0 = + cont.getStorageValue( + org.apache.tuweni.units.bigints.UInt256.ZERO); + if (slot0 == null || slot0.isZero()) { + return; + } + final long n = aereD358Retries.incrementAndGet(); + final boolean armat = + pqc.activateLateAnchor(slot0.toBytes().toUnprefixedHexString()); + if (armat || n == 1 || n % 100 == 0) { + LOG.info( + "AERE-D358: late-anchor activation retried at block {} (attempt {}): {}", + h.getNumber(), + n, + armat + ? "SUCCEEDED" + : "still pending (contract present, hash mismatch or refused)"); + } + }); + } catch (final Exception e) { + LOG.debug("AERE-D358: retry could not read the head state ({})", e.toString()); + } + }); + LOG.info( + "AERE-D358: late anchor is PENDING after the startup attempt; activation will be " + + "retried on every imported block until it succeeds or terminally fails."); + } + } + + // AERE A8 (2026-08-01): bind the Falcon registry to consensus. + // + // Deliberately placed immediately after the attachment guard and before BftExecutors, for the + // same reason: a chain head exists here, and the network and the QBFT state machine have not + // been started, so a refusal is a clean refusal to start. + // + // Deliberately placed BEFORE the attachment guard would ever let a seal be emitted: a node whose + // registry disagrees with its peers must not reach the point of signing or verifying anything. + // The two guards answer different questions - "is the activation height sane relative to this + // chain" and "is this the registry this chain requires" - and both have to be true. + // + // AERE A8 (2026-08-02): the SCHEDULE comes from the genesis configuration BESU BOOTED WITH, not + // from a genesis file re-opened by path from a system property. Re-reading a file would have + // reproduced the defect one level up: the enforced binding would again depend on a local file a + // node can be pointed at wrongly, and a node reading a stale copy would enforce a stale + // schedule, or none, in silence. Read from GenesisConfigOptions there is no second file: the + // value enforced comes out of the same object that produced this node's genesis hash, so a node + // that disagrees about the schedule already disagrees about the chain. + // + // MEASURED, and it is the input that decides the hash: the live chain 2800 genesis carries + // config.chainId = 2800, so getChainId() is PRESENT and the 0L fallback below is not the value + // in play. That had been recorded as unmeasured, and any registry hash pinned into genesis has + // to be computed under the same chain id the guard will use. + final PqRegistryHash.Schedule pqRegistrySchedule = + PqRegistryHash.parseSchedule( + aereGenesisConfigNode("pqRegistryHash"), "booted genesis config.pqRegistryHash"); + FalconSealSupport.instance() + .verifyRegistryBindingOrAbort( + blockchain.getChainHeadBlockNumber(), + genesisConfigOptions + .getChainId() + .map(java.math.BigInteger::longValue) + .orElse(0L), + pqRegistrySchedule); + + // AERE SINCRONIZARE (2026-08-02): the anchor is only a defence if it applies to the history this + // node ACCEPTS, and not only to the blocks it is offered as proposals. Outside FULL sync the + // header import path never instantiates a header validator at all, so both anchor rules would be + // skipped for the entire span between checkpoint and pivot. Refuse to start rather than run a + // node that believes it is protected and is not. + // + // Placed with the other two startup guards and for the same reason: a chain head exists here, the + // network is not up and the QBFT state machine has not been created, so this is a clean refusal + // to start. Inert when aere.pq.anchorBlock is unset, which is chain 2800 as it stands today. + // AERE GARDA-PRAG (2026-08-02): read the anchor configuration ONCE, here, and hand the SAME + // object to every guard that needs it. Two calls to fromSystemConfiguration would print the + // ARMED banner twice and, worse, would give two guards two independent chances to disagree + // about what is actually configured on this node. + final PqAnchorConfig aereAnchorConfig = PqAnchorConfig.fromSystemConfiguration(); + + PqAnchorSyncModeGuard.verifySyncModeOrAbort( + syncConfig == null || syncConfig.getSyncMode() == null + ? null + : syncConfig.getSyncMode().name(), + aereAnchorConfig); + + // AERE PERSISTENTA-SIGILII (2026-08-05): THE SECOND HALF of the chain death on a whole-fleet + // restart. The first half is repaired above (BLOCAJ-REPORNIRE): the registry now arms at startup + // from the chain head state, and all seven nodes reported SUCCEEDED. The chain died anyway, the + // refusal only changing shape: "registry address-bound=TRUE ... Heard 0 seal(s)", frozen 150 s + // on the first restart and 298 s on the second. + // + // WHY. Falcon seals over M(head) travel ONLY on the Commit messages of the head block. Those + // messages are never repeated after a restart, and they exist nowhere else: the head block + // carries in element 6 the certificate over its PARENT, not over itself. So every node started + // with zero seals, nobody reached K, nobody could propose, and so nobody sent another Commit. + // The same circular deadlock, one level down. + // + // WHY THIS IS NOT A8 IN NEW CLOTHES, and this is the whole security argument: the seal is + // SELF-VERIFYING. Every seal read from the file is cryptographically verified again against the + // anchored registry, over an M rebuilt from the head header this very process has just loaded, + // exactly as the producer does at selection time. A forged file cannot inject a seal without + // forging a Falcon-512 signature; all it can obtain is the empty cache an absent file already + // gives. A8 was a REGISTRY of keys trusted because it sat in a file. + // + // Deliberately here: the registry is already armed by the block above (otherwise no seal could + // resolve and the restore would have gone quiet for nothing), the chain head exists, and the + // network and the QBFT machine have not started. Inert when the anchor is not armed, so chain + // 2800 as it stands today sees nothing. + if (aereAnchorConfig.everActive()) { + PqSealCache.instance().enablePersistence(dataDirectory, aereAnchorConfig.chainId()); + try { + final BlockHeader aereCapSigilii = blockchain.getChainHeadHeader(); + final int aereSigiliiRestaurate = + PqSealCache.instance() + .restoreFromDisk( + aereCapSigilii.getNumber(), + aereCapSigilii.getHash(), + PqSignerRegistry.falconSealSupport()); + LOG.info( + "AERE PERSISTENTA-SIGILII: {} VERIFIED seal(s) restored for chain head {} ({}). The " + + "threshold demanded at the next height is K={}. Zero means this node cannot " + + "propose until it hears a Commit, which after a fleet restart with K>0 never " + + "happens again on its own.", + aereSigiliiRestaurate, + aereCapSigilii.getNumber(), + aereCapSigilii.getHash(), + aereAnchorConfig.minSealsAt(aereCapSigilii.getNumber() + 1L)); + } catch (final Exception e) { + LOG.warn( + "AERE PERSISTENTA-SIGILII: restoring the chain head seals could not be done ({}); the " + + "node starts with an empty cache, exactly as before this repair.", + e.toString()); + } + } + final BftExecutors bftExecutors = BftExecutors.create(metricsSystem, BftExecutors.ConsensusType.QBFT); final QbftBlockCodec blockEncoder = new QbftBlockCodecAdaptor(qbftExtraDataCodec); @@ -226,6 +518,22 @@ public class QbftBesuControllerBuilder extends BesuControllerBuilder { validatorProvider = protocolContext.getConsensusContext(BftContext.class).getValidatorProvider(); } + + // AERE GARDA-PRAG (2026-08-02): refuse to start on a seal threshold that STOPS the chain. + // + // Measured before this line was written: nothing anywhere refused a threshold value. Searched + // the whole fork tree for any occurrence of minSeals near quorum, throw, refuse, abort or + // Illegal, and there were ZERO. PqAnchorConfig rejects a negative K and a step scheduled below + // the activation height, and nothing else; the only other check, PqAnchorProducer's + // warnIfActivationHeightCanRefuse, writes one LOG.error and returns. So a value at or above the + // QBFT quorum was a configuration a node accepted in silence and then halted on. + // + // Placed here rather than beside the other three startup guards because this is the FIRST point + // at which the validator set is resolved, and the bound is a function of its size. It is still + // before the network exists (ValidatorPeers is built below) and before the QBFT state machine is + // created, so a refusal here is a clean refusal to start rather than a mid-flight halt. + PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort( + aereAnchorConfig, validatorProvider.getValidatorsAtHead().size()); final QbftValidatorProvider qbftValidatorProvider = new QbftValidatorProviderAdaptor(validatorProvider); @@ -447,6 +755,42 @@ public class QbftBesuControllerBuilder extends BesuControllerBuilder { return new BftValidatorOverrides(result); } + /** + * AERE A8: read a genesis {@code config.*} value that Besu itself does not model, out of the + * genesis configuration THIS NODE BOOTED WITH. + * + *

Besu's {@code GenesisConfigOptions.asMap()} cannot be used for this: it is an allow-list of + * the keys Besu knows about, so a key of ours is simply absent from it and the guard would read + * "no schedule" on a genesis that plainly carries one. That failure would be silent and would + * disarm the binding on exactly the fleet that had configured it, which is worse than not having + * written it. + * + *

MEASURED, and it is why the lookup is done twice: Besu normalises every genesis config key + * to lower case in {@code GenesisReader} via {@code JsonUtil.normalizeKeys}, so {@code + * pqRegistryHash} as written by an operator arrives here as {@code pqregistryhash}. Looking up + * only the camel-case spelling would find nothing on a real genesis file. Both spellings are + * tried so the guard cannot be switched off by a rebase that changes the normalisation. + * + *

Returns null when the key is absent, which {@code PqRegistryHash.parseSchedule} turns into + * the empty schedule, i.e. nothing is enforced and the existing chain is untouched. + */ + private com.fasterxml.jackson.databind.JsonNode aereGenesisConfigNode(final String key) { + if (!(genesisConfigOptions instanceof JsonGenesisConfigOptions)) { + LOG.warn( + "AERE PQC A8: the genesis configuration is a {}, not the JSON-backed implementation, so " + + "config.{} cannot be read and the Falcon registry binding is NOT ENFORCED on this " + + "node. A binding everybody believes is on and is not is worse than no binding.", + genesisConfigOptions.getClass().getName(), + key); + return null; + } + final JsonGenesisConfigOptions json = (JsonGenesisConfigOptions) genesisConfigOptions; + final com.fasterxml.jackson.databind.JsonNode exact = json.aereRawConfigValue(key); + return exact != null + ? exact + : json.aereRawConfigValue(key.toLowerCase(java.util.Locale.ROOT)); + } + private static MinedBlockObserver blockLogger( final TransactionPool transactionPool, final Address localAddress) { return block -> diff --git a/app/src/test/java/org/hyperledger/besu/cli/options/AerePqEmergencyOptionsTest.java b/app/src/test/java/org/hyperledger/besu/cli/options/AerePqEmergencyOptionsTest.java new file mode 100755 index 000000000..b77a549b2 --- /dev/null +++ b/app/src/test/java/org/hyperledger/besu/cli/options/AerePqEmergencyOptionsTest.java @@ -0,0 +1,136 @@ +/* + * Copyright contributors to Besu / AERE Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.cli.options; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.hyperledger.besu.consensus.common.bft.FalconSealSupport; +import org.hyperledger.besu.consensus.common.bft.PqAnchorConfig; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import picocli.CommandLine; + +/** + * AERE OPTIUNI-URGENTA: the command line really reaches the controls. + * + *

These options exist for exactly one reason - that standing the post-quantum anchor down must be + * a RESTART and never a rebuild - so the thing worth asserting is the hand-off: a flag typed on the + * command line ends up in the same system property the control has always read, and it wins over a + * property already set, so an operator who types the flag does not have to know what a wrapper + * script did earlier. + */ +class AerePqEmergencyOptionsTest { + + private static final String[] PROPERTIES = { + PqAnchorConfig.PROPERTY_DISABLE, + PqAnchorConfig.PROPERTY_MIN_SEALS_CEILING, + FalconSealSupport.PROPERTY_REGISTRY_MISMATCH_ALLOW + }; + + @BeforeEach + @AfterEach + void clearProperties() { + for (final String property : PROPERTIES) { + System.clearProperty(property); + } + } + + private AerePqEmergencyOptions parse(final String... args) { + final AerePqEmergencyOptions options = AerePqEmergencyOptions.create(); + new CommandLine(options).parseArgs(args); + return options; + } + + @Test + void nothingOnTheCommandLineTouchesNothing() { + parse().applyAndAnnounce(); + for (final String property : PROPERTIES) { + assertThat(System.getProperty(property)).isNull(); + } + } + + @Test + void bareDisarmFlagArmsTheDisarm() { + parse("--Xaere-pq-anchor-disarm").applyAndAnnounce(); + assertThat(System.getProperty(PqAnchorConfig.PROPERTY_DISABLE)).isEqualTo("true"); + } + + @Test + void disarmCanBeSpelledOutAndCanBeSpelledFalse() { + parse("--Xaere-pq-anchor-disarm=false").applyAndAnnounce(); + assertThat(System.getProperty(PqAnchorConfig.PROPERTY_DISABLE)).isEqualTo("false"); + } + + @Test + void theCommandLineBeatsAPropertySetEarlier() { + // The case this protects: a wrapper script or a unit file left the disarm on, and the operator + // now wants it off. Typing the flag has to be enough. + System.setProperty(PqAnchorConfig.PROPERTY_DISABLE, "true"); + parse("--Xaere-pq-anchor-disarm=false").applyAndAnnounce(); + assertThat(System.getProperty(PqAnchorConfig.PROPERTY_DISABLE)).isEqualTo("false"); + } + + @Test + void ceilingReachesTheProperty() { + parse("--Xaere-pq-anchor-min-seals-max=2").applyAndAnnounce(); + assertThat(System.getProperty(PqAnchorConfig.PROPERTY_MIN_SEALS_CEILING)).isEqualTo("2"); + } + + @Test + void aCeilingOfZeroIsAReadableValueAndNotAnAbsentOne() { + parse("--Xaere-pq-anchor-min-seals-max=0").applyAndAnnounce(); + assertThat(System.getProperty(PqAnchorConfig.PROPERTY_MIN_SEALS_CEILING)).isEqualTo("0"); + } + + @Test + void aNegativeCeilingRefusesToStartRatherThanBeingIgnored() { + assertThatThrownBy(() -> parse("--Xaere-pq-anchor-min-seals-max=-1").applyAndAnnounce()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("must not be negative"); + assertThat(System.getProperty(PqAnchorConfig.PROPERTY_MIN_SEALS_CEILING)).isNull(); + } + + @Test + void registryBypassReachesTheProperty() { + parse("--Xaere-pq-registry-mismatch-allow").applyAndAnnounce(); + assertThat(System.getProperty(FalconSealSupport.PROPERTY_REGISTRY_MISMATCH_ALLOW)) + .isEqualTo("true"); + } + + @Test + void allThreeCanBeGivenTogether() { + parse( + "--Xaere-pq-anchor-disarm", + "--Xaere-pq-anchor-min-seals-max=0", + "--Xaere-pq-registry-mismatch-allow") + .applyAndAnnounce(); + assertThat(System.getProperty(PqAnchorConfig.PROPERTY_DISABLE)).isEqualTo("true"); + assertThat(System.getProperty(PqAnchorConfig.PROPERTY_MIN_SEALS_CEILING)).isEqualTo("0"); + assertThat(System.getProperty(FalconSealSupport.PROPERTY_REGISTRY_MISMATCH_ALLOW)) + .isEqualTo("true"); + } + + @Test + void whatTheCommandLineWritesIsWhatPqAnchorConfigThenReads() { + // The hand-off end to end, through the real reader rather than a re-implementation of it. + parse("--Xaere-pq-anchor-disarm", "--Xaere-pq-anchor-min-seals-max=1").applyAndAnnounce(); + final PqAnchorConfig read = PqAnchorConfig.fromSystemConfiguration(); + assertThat(read.disabled()).isTrue(); + assertThat(read.minSealsCeiling()).hasValue(1); + } +} diff --git a/config/src/main/java/org/hyperledger/besu/config/JsonGenesisConfigOptions.java b/config/src/main/java/org/hyperledger/besu/config/JsonGenesisConfigOptions.java index eeaa4ac3e..79581600f 100644 --- a/config/src/main/java/org/hyperledger/besu/config/JsonGenesisConfigOptions.java +++ b/config/src/main/java/org/hyperledger/besu/config/JsonGenesisConfigOptions.java @@ -11,6 +11,12 @@ * specific language governing permissions and limitations under the License. * * SPDX-License-Identifier: Apache-2.0 + * + * Modifications Copyright contributors to the Aere Network. + * + * This file was modified by contributors to the Aere Network, as required by section 4(b) of the + * Apache License 2.0. The copyright header above is the upstream one and is left exactly as it was + * found, as section 4(c) requires. The change itself is in patches/0003-aere-pq-anchor.patch. */ package org.hyperledger.besu.config; @@ -639,4 +645,20 @@ public class JsonGenesisConfigOptions implements GenesisConfigOptions { public int hashCode() { return Objects.hash(configRoot, configOverrides); } + + /** + * AERE A8: the raw genesis {@code config.*} value for a key Besu does not model, or null. + * + *

WHY THIS EXISTS. {@link #asMap()} is an allow-list of the keys Besu knows, so a key of ours + * is absent from it, and a guard reading it would conclude "no schedule" on a genesis that + * plainly carries one. Silently disarming a binding on exactly the fleet that configured it is + * worse than never having written the binding. This accessor is additive and read-only: it + * changes no Besu behaviour and returns the node exactly as the genesis reader normalised it. + * + * @param key the config key, as normalised by the genesis reader (lower case) + * @return the value node, or null when absent + */ + public com.fasterxml.jackson.databind.JsonNode aereRawConfigValue(final String key) { + return configRoot.get(key); + } } diff --git a/consensus/common/build.gradle b/consensus/common/build.gradle index 499c974f1..a6f5276f2 100644 --- a/consensus/common/build.gradle +++ b/consensus/common/build.gradle @@ -11,6 +11,12 @@ * specific language governing permissions and limitations under the License. * * SPDX-License-Identifier: Apache-2.0 + * + * Modifications Copyright contributors to the Aere Network. + * + * This file was modified by contributors to the Aere Network, as required by section 4(b) of the + * Apache License 2.0. The copyright header above is the upstream one and is left exactly as it was + * found, as section 4(c) requires. The change itself is in patches/0003-aere-pq-anchor.patch. */ apply plugin: 'java-library' @@ -47,6 +53,7 @@ dependencies { implementation 'com.fasterxml.jackson.core:jackson-databind' implementation 'com.google.guava:guava' + implementation 'org.bouncycastle:bcprov-jdk18on' implementation 'io.consensys.tuweni:tuweni-bytes' testImplementation project(':config') diff --git a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/AereAnchorProposalDelay.java b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/AereAnchorProposalDelay.java new file mode 100755 index 000000000..84eeb15a4 --- /dev/null +++ b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/AereAnchorProposalDelay.java @@ -0,0 +1,152 @@ +/* + * Copyright contributors to Aere Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in + * compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is + * distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the License for the specific language governing permissions and limitations under the + * License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +import org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * D-337 (2026-09-04): how long the proposer waits, ONLY at an anchor height, before its proposal + * timer fires. + * + *

WHY THIS EXISTS, measured on chain 2800. At an anchor height the proposer must carry K valid + * seals of EVERY scheme in the schedule, and the SLH-DSA seals arrive with the commits of the + * parent. With the hybrid certificate live, the proposal timer fires about half a second after the + * parent and the sixth SLH-DSA seal is not there yet, so the producer refuses (it will not write a + * certificate it cannot fill), round 0 expires, and the anchor costs the whole four-second + * round-change timeout: eleven of twelve anchors measured on 2026-09-04, parent-to-anchor 5.6 s + * instead of 0.8 s. Making the signature four times faster fixed the PARENT (3.3 s to 1 s) and did + * not fix this, because the race is against the proposal timer, not against the CPU. + * + *

WHAT IT IS NOT. This is local timing, not consensus. A node that does not set it proposes + * exactly as before; a proposal that arrives later is valid under the same rules (QBFT only + * requires the timestamp to be at least a block period after the parent). So it can be set node by + * node, with no activation height and no fleet agreement, and a wrong value costs liveness at + * anchors, never a fork. + * + *

Unset or zero means today's behaviour, exactly. The value is read once, validated at node + * start (from {@link BlockTimer}'s constructor), and refused if it is negative or above five + * seconds: a delay longer than the round-change timeout would trade one stall for another. + */ +public final class AereAnchorProposalDelay { + private static final Logger LOG = LoggerFactory.getLogger(AereAnchorProposalDelay.class); + + /** System property carrying the delay in milliseconds. */ + public static final String PROPERTY = "aere.pq.anchorProposalDelayMs"; + + /** Environment variable carrying the delay in milliseconds. */ + public static final String ENV = "AERE_PQ_ANCHOR_PROPOSAL_DELAY_MS"; + + /** The largest delay that can be configured; above this a stall is traded for a stall. */ + public static final long MAX_MILLIS = 5000L; + + private static volatile Long cached; + + private AereAnchorProposalDelay() {} + + /** + * The configured delay in milliseconds, read once and memoised. + * + * @return the delay, zero when unset + * @throws IllegalArgumentException if the value is not a number in [0, {@link #MAX_MILLIS}] + */ + public static long configuredMillis() { + Long local = cached; + if (local == null) { + synchronized (AereAnchorProposalDelay.class) { + local = cached; + if (local == null) { + local = parse(readRaw()); + if (local > 0) { + LOG.info( + "AERE PQ-ANCHOR: proposals at anchor heights wait an extra {} ms for the seals of" + + " the parent (D-337). This is local timing, not consensus.", + local); + } + cached = local; + } + } + } + return local; + } + + /** + * The delay to add to the proposal timer for a given height: the configured value at an anchor + * height, zero everywhere else. + * + * @param blockNumber the height whose proposal timer is being started + * @return milliseconds to add + */ + public static long millisFor(final long blockNumber) { + final long delay = configuredMillis(); + if (delay <= 0) { + return 0L; + } + try { + return PqAnchorProducer.config().anchorAppliesAt(blockNumber) ? delay : 0L; + } catch (final RuntimeException e) { + // The anchor configuration is read at start and refuses there; if it somehow cannot be read + // here, the honest answer is "no extra delay", never an exception into the block timer. + LOG.warn("AERE PQ-ANCHOR: cannot tell whether {} is an anchor height: {}", blockNumber, e.getMessage()); + return 0L; + } + } + + /** + * Parse and validate a raw value. + * + * @param raw the text, possibly null or blank + * @return the delay in milliseconds + */ + static long parse(final String raw) { + if (raw == null || raw.isBlank()) { + return 0L; + } + final long value; + try { + value = Long.parseLong(raw.trim()); + } catch (final NumberFormatException e) { + throw new IllegalArgumentException( + "AERE-PQC-ANCHOR-CONF-05: " + PROPERTY + "='" + raw + "' is not a number of milliseconds"); + } + if (value < 0 || value > MAX_MILLIS) { + throw new IllegalArgumentException( + "AERE-PQC-ANCHOR-CONF-05: " + + PROPERTY + + "=" + + value + + " is outside [0, " + + MAX_MILLIS + + "] ms. A delay longer than the round-change timeout trades one stall for another."); + } + return value; + } + + private static String readRaw() { + final String property = System.getProperty(PROPERTY); + return property != null ? property : System.getenv(ENV); + } + + /** + * Drop the memoised value. For tests only. + */ + static void forgetForTesting() { + synchronized (AereAnchorProposalDelay.class) { + cached = null; + } + } +} diff --git a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/BftBlockInterface.java b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/BftBlockInterface.java index b7a886c29..77af53d68 100644 --- a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/BftBlockInterface.java +++ b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/BftBlockInterface.java @@ -11,6 +11,12 @@ * specific language governing permissions and limitations under the License. * * SPDX-License-Identifier: Apache-2.0 + * + * Modifications Copyright contributors to the Aere Network. + * + * This file was modified by contributors to the Aere Network, as required by section 4(b) of the + * Apache License 2.0. The copyright header above is the upstream one and is left exactly as it was + * found, as section 4(c) requires. The change itself is in patches/0003-aere-pq-anchor.patch. */ package org.hyperledger.besu.consensus.common.bft; @@ -79,6 +85,19 @@ public class BftBlockInterface implements BlockInterface { /** * Replace round in block. * + *

AERE ANCHOR V2, MEASURED DEFECT, FIXED HERE. This method used the five argument {@link + * BftExtraData} constructor, which defaults the Falcon seal collection to EMPTY, so every round + * replacement silently DELETED element 6. That is harmless while element 6 only carries a + * log-only attestation, and fatal under V2: the round change path + * ({@code QbftRound.startRoundWith} to {@code replaceRoundAndProposerForProposalBlock}) re-uses a + * prepared block, and it would have re-proposed it with vanityData still carrying the digest D of + * a certificate that had just been stripped out. The digest rule would then reject every single + * re-proposed block, on a chain where rounds above zero do occur (measured 1 in 307). + * + *

Carrying the seals across is safe for every existing user of this method, because the Falcon + * list is excluded from BOTH hash pre-images: neither the committed-seal hash nor the on-chain + * block hash changes. IBFT, which shares this class, always has an empty list here. + * * @param block the block * @param round the round * @param blockHeaderFunctions the block header functions @@ -93,7 +112,12 @@ public class BftBlockInterface implements BlockInterface { prevExtraData.getSeals(), prevExtraData.getVote(), round, - prevExtraData.getValidators()); + prevExtraData.getValidators(), + prevExtraData.getFalconSeals(), + // AERE ANCHOR V2 (2026-09-03, D-328): the scheme-tagged certificate rides along too. Without + // this line every v2 header lost its certificate at the round substitution and the fleet + // refused its own first v2 anchor (testnet 28001, block 168032, 3 hours stalled). + prevExtraData.getHybridSeals()); final BlockHeaderBuilder headerBuilder = BlockHeaderBuilder.fromHeader(block.getHeader()); headerBuilder diff --git a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/BftExtraData.java b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/BftExtraData.java index a24c33a93..c444a1514 100644 --- a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/BftExtraData.java +++ b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/BftExtraData.java @@ -11,6 +11,12 @@ * specific language governing permissions and limitations under the License. * * SPDX-License-Identifier: Apache-2.0 + * + * Modifications Copyright contributors to the Aere Network. + * + * This file was modified by contributors to the Aere Network, as required by section 4(b) of the + * Apache License 2.0. The copyright header above is the upstream one and is left exactly as it was + * found, as section 4(c) requires. The change itself is in patches/0003-aere-pq-anchor.patch. */ package org.hyperledger.besu.consensus.common.bft; @@ -21,6 +27,8 @@ import org.hyperledger.besu.datatypes.Address; import org.hyperledger.besu.ethereum.core.ParsedExtraData; import java.util.Collection; +import java.util.Collections; +import java.util.List; import java.util.Optional; import org.apache.tuweni.bytes.Bytes; @@ -34,7 +42,17 @@ public class BftExtraData implements ParsedExtraData { private final int round; /** - * Instantiates a new Bft extra data. + * Parallel, ADDITIVE post-quantum (Falcon-512) seals. These sit alongside the decisive ECDSA + * {@link #seals} and are excluded from every signed pre-image, so they never disturb the ECDSA + * committed seals or the block/header hash. + */ + private final Collection falconSeals; + /** AERE ANCHOR V2: the scheme-tagged certificate; empty on every header below the v2 height. */ + private final List hybridSeals; + + /** + * Instantiates a new Bft extra data (no Falcon seals). Retained for callers, notably the IBFT + * codec, that never carry a parallel post-quantum seal. * * @param vanityData the vanity data * @param seals the seals @@ -48,14 +66,59 @@ public class BftExtraData implements ParsedExtraData { final Optional vote, final int round, final Collection

validators) { + this(vanityData, seals, vote, round, validators, Collections.emptyList()); + } + + /** + * Instantiates a new Bft extra data with a parallel Falcon-512 seal list. + * + * @param vanityData the vanity data + * @param seals the ECDSA committed seals (decisive) + * @param vote the vote + * @param round the round + * @param validators the validators + * @param falconSeals the parallel post-quantum Falcon seals (additive, log-only) + */ + public BftExtraData( + final Bytes vanityData, + final Collection seals, + final Optional vote, + final int round, + final Collection
validators, + final Collection falconSeals) { + this(vanityData, seals, vote, round, validators, falconSeals, Collections.emptyList()); + } + + /** + * Full constructor. + * + * @param hybridSeals the scheme-tagged (v2) certificate, or empty; a header carries the v1 Falcon + * list OR the v2 certificate, never both + */ + public BftExtraData( + final Bytes vanityData, + final Collection seals, + final Optional vote, + final int round, + final Collection
validators, + final Collection falconSeals, + final List hybridSeals) { checkNotNull(vanityData); + checkNotNull(hybridSeals); + if (!hybridSeals.isEmpty() && falconSeals != null && !falconSeals.isEmpty()) { + throw new IllegalArgumentException( + "AERE ANCHOR V2: a header carries the v1 Falcon list OR the v2 certificate, not both"); + } + this.hybridSeals = List.copyOf(hybridSeals); checkNotNull(seals); checkNotNull(validators); + checkNotNull(falconSeals); this.vanityData = vanityData; this.seals = seals; this.validators = validators; this.vote = vote; this.round = round; + this.falconSeals = falconSeals; } /** @@ -103,6 +166,24 @@ public class BftExtraData implements ParsedExtraData { return round; } + /** + * Gets the parallel post-quantum Falcon seals. + * + * @return the Falcon seals (possibly empty, never null) + */ + public Collection getFalconSeals() { + return falconSeals; + } + + /** + * The scheme-tagged (v2) certificate. + * + * @return the v2 seals, canonical order; empty below the v2 height + */ + public List getHybridSeals() { + return hybridSeals; + } + @Override public String toString() { return "BftExtraData{" @@ -116,6 +197,10 @@ public class BftExtraData implements ParsedExtraData { + vote + ", round=" + round + + ", falconSeals=" + + falconSeals + + ", hybridSeals=" + + hybridSeals + '}'; } } diff --git a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/BlockTimer.java b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/BlockTimer.java index 80f6815c8..73493403c 100644 --- a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/BlockTimer.java +++ b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/BlockTimer.java @@ -11,6 +11,12 @@ * specific language governing permissions and limitations under the License. * * SPDX-License-Identifier: Apache-2.0 + * + * Modifications Copyright contributors to the Aere Network. + * + * This file was modified by contributors to the Aere Network, as required by section 4(b) of the + * Apache License 2.0. The copyright header above is the upstream one and is left exactly as it was + * found, as section 4(c) requires. The change itself is in patches/0003-aere-pq-anchor.patch. */ package org.hyperledger.besu.consensus.common.bft; @@ -53,6 +59,12 @@ public class BlockTimer { final ForksSchedule forksSchedule, final BftExecutors bftExecutors, final Clock clock) { + // Load the anchor proposal-delay configuration HERE, at construction, so a malformed value + // refuses START-UP rather than surfacing on its first use inside the consensus state machine. + // Measured 2026-09-03 on mainnet: a lazily-loaded consensus setting turned an unreadable file + // into 125 seconds of halted chain, while every configuration gate stayed green because the + // files existed. + AereAnchorProposalDelay.configuredMillis(); this.queue = queue; this.forksSchedule = forksSchedule; this.bftExecutors = bftExecutors; @@ -123,7 +135,13 @@ public class BlockTimer { final int emptyBlockPeriodSeconds = currentForkOptions.getEmptyBlockPeriodSeconds(); setBlockTimes(currentBlockPeriodSeconds, emptyBlockPeriodSeconds); - startTimer(round, expiryTime); + // AERE D-337 (2026-09-04): at an anchor height the proposer needs K seals of every scheme, and + // those ride on the commits of the parent. Measured on chain 2800, the timer fires before the + // sixth SLH-DSA seal arrives, the producer refuses to write a certificate it cannot fill, and + // the anchor pays the whole round-change timeout. The extra wait applies ONLY at anchor heights + // and is zero unless configured; see AereAnchorProposalDelay for why this is local timing and + // not consensus. + startTimer(round, expiryTime + AereAnchorProposalDelay.millisFor(round.getSequenceNumber())); } /** diff --git a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/FalconSeal.java b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/FalconSeal.java new file mode 100755 index 000000000..9f290a1aa --- /dev/null +++ b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/FalconSeal.java @@ -0,0 +1,88 @@ +/* + * Copyright contributors to Besu / AERE Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +import java.util.Objects; + +import org.apache.tuweni.bytes.Bytes; + +/** + * A parallel post-quantum (Falcon-512) seal attached to a BFT block header, ALONGSIDE (never + * replacing) the decisive ECDSA committed seals. + * + *

Because Falcon signatures are not public-key-recoverable, each seal carries an explicit {@code + * validatorIndex} so a verifier can look up the signer's Falcon public key in the validator to + * Falcon-pubkey registry. This is an additive, log-only attestation for the hybrid PQC demo; ECDSA + * committed seals remain the liveness-critical seal that decides block acceptance. + */ +public class FalconSeal { + private final int validatorIndex; + private final Bytes signature; + + /** + * Instantiates a new Falcon seal. + * + * @param validatorIndex the index of the signing validator in the Falcon registry + * @param signature the Falcon-512 signature over the block commit hash + */ + public FalconSeal(final int validatorIndex, final Bytes signature) { + this.validatorIndex = validatorIndex; + this.signature = signature; + } + + /** + * Gets the validator index. + * + * @return the validator index used to look up the Falcon public key + */ + public int getValidatorIndex() { + return validatorIndex; + } + + /** + * Gets the Falcon signature bytes. + * + * @return the Falcon-512 signature + */ + public Bytes getSignature() { + return signature; + } + + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + final FalconSeal that = (FalconSeal) o; + return validatorIndex == that.validatorIndex && Objects.equals(signature, that.signature); + } + + @Override + public int hashCode() { + return Objects.hash(validatorIndex, signature); + } + + @Override + public String toString() { + return "FalconSeal{validatorIndex=" + + validatorIndex + + ", sigLen=" + + (signature == null ? 0 : signature.size()) + + '}'; + } +} diff --git a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/FalconSealScheme.java b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/FalconSealScheme.java new file mode 100755 index 000000000..0f0090cb3 --- /dev/null +++ b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/FalconSealScheme.java @@ -0,0 +1,104 @@ +/* AERE crypto-agility: Falcon-512 behind the SealScheme seam. The registry form is the raw + * Falcon h vector, 896 bytes, exactly what the signer registry stores today (measured on the + * proof-network registry files, registru-PROBA-v2-*.properties: 896 per entry). The 897-byte + * form pk(897) = 0x09 || h belongs to the 0x0AE1 PRECOMPILE input format, one header byte above + * this layer; confusing the two costs a red test, which is exactly how this comment was earned. */ +package org.hyperledger.besu.consensus.common.bft; + +import java.security.SecureRandom; +import java.util.Optional; + +import org.bouncycastle.pqc.crypto.falcon.FalconKeyGenerationParameters; +import org.bouncycastle.pqc.crypto.falcon.FalconKeyPairGenerator; +import org.bouncycastle.pqc.crypto.falcon.FalconParameters; +import org.bouncycastle.pqc.crypto.falcon.FalconPrivateKeyParameters; +import org.bouncycastle.pqc.crypto.falcon.FalconPublicKeyParameters; +import org.bouncycastle.pqc.crypto.falcon.FalconSigner; +import org.bouncycastle.crypto.AsymmetricCipherKeyPair; + +/** Falcon-512 as a pluggable seal scheme. */ +public final class FalconSealScheme implements SealScheme { + + /** Registry form: the raw public h vector for Falcon-512 (no precompile header byte). */ + public static final int PUBLIC_KEY_LENGTH = 896; + + private record Pub(FalconPublicKeyParameters params) implements PublicHandle {} + + private record Priv(FalconPrivateKeyParameters params) implements PrivateHandle {} + + @Override + public String id() { + return "falcon-512"; + } + + @Override + public byte wireId() { + return 0x01; + } + + @Override + public int publicKeyLength() { + return PUBLIC_KEY_LENGTH; + } + + @Override + public Optional parsePublicKey(final byte[] registryForm) { + if (registryForm == null || registryForm.length != PUBLIC_KEY_LENGTH) { + return Optional.empty(); + } + try { + return Optional.of(new Pub(new FalconPublicKeyParameters(FalconParameters.falcon_512, registryForm))); + } catch (final RuntimeException e) { + return Optional.empty(); + } + } + + @Override + public Optional sign(final PrivateHandle key, final byte[] message) { + if (!(key instanceof Priv p) || message == null) { + return Optional.empty(); + } + try { + final FalconSigner signer = new FalconSigner(); + signer.init(true, p.params()); + return Optional.of(signer.generateSignature(message)); + } catch (final RuntimeException e) { + return Optional.empty(); + } + } + + @Override + public boolean verify(final PublicHandle key, final byte[] message, final byte[] signature) { + if (!(key instanceof Pub p) || message == null || signature == null) { + return false; + } + try { + final FalconSigner verifier = new FalconSigner(); + verifier.init(false, p.params()); + return verifier.verifySignature(message, signature); + } catch (final RuntimeException e) { + return false; + } + } + + /** Transition bridge for the live signing path: FalconSealSupport loads the node's private + * key as BC {@link FalconPrivateKeyParameters} long before this layer existed. Routing its + * signing through the scheme without re-plumbing key loading needs this one adapter. The BC + * type appears ONLY here, in the class whose whole job is to speak Falcon. */ + public Optional signWithParams(final FalconPrivateKeyParameters key, final byte[] message) { + if (key == null || message == null) { + return Optional.empty(); + } + return sign(new Priv(key), message); + } + + @Override + public GeneratedPair generate(final SecureRandom random) { + final FalconKeyPairGenerator gen = new FalconKeyPairGenerator(); + gen.init(new FalconKeyGenerationParameters(random, FalconParameters.falcon_512)); + final AsymmetricCipherKeyPair pair = gen.generateKeyPair(); + final FalconPublicKeyParameters pub = (FalconPublicKeyParameters) pair.getPublic(); + final FalconPrivateKeyParameters priv = (FalconPrivateKeyParameters) pair.getPrivate(); + return new GeneratedPair(new Pub(pub), new Priv(priv), pub.getH()); + } +} diff --git a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/FalconSealSupport.java b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/FalconSealSupport.java new file mode 100755 index 000000000..7049b8414 --- /dev/null +++ b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/FalconSealSupport.java @@ -0,0 +1,4130 @@ +/* + * Copyright contributors to Besu. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +import java.io.FileInputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.Iterator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.OptionalInt; +import java.util.Properties; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.LongPredicate; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.tuweni.bytes.Bytes; +import org.apache.tuweni.bytes.Bytes32; +import org.bouncycastle.crypto.digests.KeccakDigest; +import org.bouncycastle.pqc.crypto.falcon.FalconParameters; +import org.bouncycastle.pqc.crypto.falcon.FalconPrivateKeyParameters; +import org.bouncycastle.pqc.crypto.falcon.FalconPublicKeyParameters; +import org.bouncycastle.pqc.crypto.falcon.FalconSigner; +import org.hyperledger.besu.datatypes.Address; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * BFT-local, PARALLEL Falcon-512 signer + verifier + registry for the Falcon consensus SEAL: a + * post-quantum signature carried ALONGSIDE the classical seals. + * + *

NAMING, and this is not pedantry. This line used to read "for the hybrid post-quantum + * consensus seal". AERE DOES NOT HAVE POST-QUANTUM CONSENSUS and must never be described as having + * it: proposer selection and finality are classical secp256k1 ECDSA QBFT, and the post-quantum + * layer is signature, precompile and account level. Our own audit scope dossier + * ({@code audit-package-pq-consensus/scope/AUDIT-SCOPE-DOSSIER-2026-07-12.md}, section 3) tells + * reviewers to FLAG that phrase wherever it appears in code comments. It appeared here. + * + *

This deliberately does NOT ride the shared secp256k1 {@code NodeKey} / {@code SecurityModule} + * singleton (that interface returns ECDSA R,S and is used chain-wide for transactions and devp2p). + * The Falcon key here is a second, consensus-only key. + * + *

AERE audit fix (AUD-CONSENSUS-1 / AUD-CONSENSUS-2, 2026-07-18): the registry now binds each + * Falcon-512 public key to a validator ADDRESS, not merely to an index. This is what makes the + * "eligible signer" set {@code currentValidators INTERSECT registry} well defined, so the blocking + * rule can compute BOTH the Falcon quorum AND the counted-seal set over the SAME set of validator + * addresses (see {@code FalconSealValidationRule}). Without the address binding the registry is only + * a bag of indexed keys with no tie to the live validator set, which is exactly the decoupling that + * let a routine add-validator vote raise the ECDSA-sized quorum above the fixed key count and halt a + * post-fork chain, and let a removed validator's key keep counting. + * + *

Configuration (system property takes precedence over environment variable): + * + *

    + *
  • {@code aere.falcon.key} / {@code AERE_FALCON_KEY}: path to this node's Falcon private key + * .properties file with keys {@code index, f, g, F, pk} (hex, low-level BouncyCastle Falcon + * components). This is a per-node local SECRET and never appears in any registry. + *
  • {@code aere.falcon.genesis} / {@code AERE_FALCON_GENESIS}: path to the chain's genesis file + * carrying a GENESIS-ANCHORED validator-to-Falcon-pubkey manifest. Tamper-evident and + * immutable from genesis. Highest precedence. + *
  • {@code aere.falcon.manifest} / {@code AERE_FALCON_MANIFEST} together with {@code + * aere.falcon.anchor.address} / {@code AERE_FALCON_ANCHOR_ADDRESS}: the STAGE-2 + * LATE-ANCHOR (contract-anchored) registry source for chains that launched WITHOUT a genesis + * manifest (i.e. an already-live chain). The manifest file carries the full + * index-to-{addr,pk} JSON object; the anchor contract's storage slot 0 must hold + * {@code keccak256(canonical manifest)}. The registry stays EMPTY (pending) until consensus + * code observes the anchor contract in the chain's world state and calls {@link + * #activateLateAnchor}; activation is fail-closed on any hash mismatch. This removes the + * re-genesis requirement for activating the hybrid PQC certificate on an existing chain. + *
  • {@code aere.falcon.registry} / {@code AERE_FALCON_REGISTRY}: (LEGACY, mutable-file + * fallback) path to the validator to Falcon-pubkey registry .properties file. Lowest + * precedence; retained for backward compatibility and the upstream unit tests. This legacy + * source carries pubkeys ONLY (no validator address), so a registry loaded from it is NOT + * address-bound and is NOT eligible to arm blocking (see {@link #addressBound()}). + *
  • {@code aere.falcon.forkBlock} / {@code AERE_FALCON_FORKBLOCK}: block number at and after + * which the Falcon quorum certificate is BLOCKING. Unset means never blocking (log-only). + *
+ * + *

Every method is defensive: any load, parse, sign or verify failure is swallowed and logged, so + * a Falcon fault never THROWS on the consensus path. + * + *

THAT IS NO LONGER THE SAME THING AS "IT CANNOT HALT THE CHAIN", and the sentence that used to + * claim so has been removed rather than softened. Not throwing only means the failure is returned + * as a verdict, and two consumers now turn that verdict into a REJECT: {@code + * FalconSealValidationRule} once {@code aere.falcon.forkBlock} is reached with an active registry, + * and {@code PqAnchorSealsRule} (V2 R2) at and above {@code pqAnchorBlock}, which refuses a header + * whenever {@link #addressForIndex} returns null or {@link #verify} returns false. A node whose + * registry file fails to load therefore rejects every header carrying a certificate, i.e. it halts + * itself, and two nodes with DIFFERENT registry files disagree about which headers are valid. That + * is defect A8 and it is why the registry has to be bound to genesis by {@code pqRegistryHash} + * before any of this is armed. While both of those gates are unset - which is the state of chain + * 2800 today - the subsystem is log-only and the old sentence holds; it is not a property of this + * file, it is a property of the configuration. + */ +public final class FalconSealSupport { + + private static final Logger LOG = LoggerFactory.getLogger(FalconSealSupport.class); + + private static volatile FalconSealSupport instance; + + /** + * System anchor address whose storage slot 0 holds keccak256 of the canonical Falcon manifest. + * Because genesis {@code alloc} storage is committed into the genesis stateRoot (and hence the + * genesis block hash), the manifest hash is bound to the chain identity itself: two nodes that + * agree on the chain necessarily agree on the manifest hash, and it is independently queryable + * via {@code eth_getStorageAt(0x..0fa1, 0x0)}. 40-hex, lower-case, no 0x prefix. + */ + private static final String ANCHOR_ADDRESS = "0000000000000000000000000000000000000fa1"; + + /** Storage slot (0) at the anchor address holding the manifest hash. 64-hex, no 0x prefix. */ + private static final String ANCHOR_SLOT = + "0000000000000000000000000000000000000000000000000000000000000000"; + + /** How many seals this node emitted on its own PREPAREs. See {@link #preparesSealed()}. */ + private final java.util.concurrent.atomic.AtomicLong preparesSealed = + new java.util.concurrent.atomic.AtomicLong(); + + /** How many seals this node emitted on its own PROPOSALs since startup. */ + private final java.util.concurrent.atomic.AtomicLong proposalsSealed = + new java.util.concurrent.atomic.AtomicLong(); + + /** How many seals this node emitted on its own ROUND-CHANGEs since startup. */ + private final java.util.concurrent.atomic.AtomicLong roundChangesSealed = + new java.util.concurrent.atomic.AtomicLong(); + + private final boolean signingEnabled; + private final int localIndex; + private final FalconPrivateKeyParameters localPrivateKey; + private final Map registry; + + /** + * Validator ADDRESS bound to each registry index (AUD-CONSENSUS-1 fix). Populated in lockstep + * with {@link #registry} from an address-bound manifest; empty for the legacy pubkey-only source. + * This is the map that makes {@code currentValidators INTERSECT registry} computable. + */ + private final Map indexToAddress; + + private final boolean genesisAnchored; + + /** + * AERE A8: the registry file this node ACTUALLY used, and which of the three sources it came from. + * Recorded so the consensus-binding guard can re-read exactly that file and hash it, rather than + * hashing something adjacent to it. Null when no registry is configured at all. + */ + private final String registrySourcePath; + + private final PqRegistryHash.SourceKind registrySourceKind; + + /** Outcome of the A8 registry-binding guard; NOT_CHECKED equivalent is null until it has run. */ + private volatile PqRegistryHash.GateState registryBindingState; + + /** + * AERE A8 (per-block half): the schedule the startup guard actually enforced, the registry object + * it hashed, and the chain id it hashed under. Held so that {@link #registryBindingSatisfiedAt} + * can answer the SAME question on the per-block consensus path without re-reading a file 61 + * million times a year, and without a second, drifting copy of the decision. + * + *

Null until the guard has run. Null means the per-block rule is INERT, which is the only safe + * default: a node whose controller never called the guard (a non-QBFT path, or a unit test) must + * not start rejecting headers because of a binding nobody configured. + */ + private volatile PqRegistryHash.Schedule registryBindingSchedule; + + private volatile PqRegistryHash.Registry registryBindingLoaded; + + /** + * D-081: every registry this node holds, bound to the schedule entry each one satisfies. + * + *

{@link #registryBindingLoaded} above is the registry for the HEAD, and it is what the node + * signs with. It is kept because every diagnostic message names it. This field is the whole + * scheduled history, and it is what the per-block binding question and height-resolved + * verification are answered from. With no history configured the set holds exactly the one + * registry above, so both answers are bit-for-bit what they were before D-081. + */ + private volatile PqRegistryHash.RegistrySet registryBindingSet; + + /** D-081: per (schedule entry, index) Falcon public keys, built on demand from the set. */ + private final Map historicalKeys = new ConcurrentHashMap<>(); + + private volatile long registryBindingChainId; + + /** Highest height at which the per-block binding rule has already shouted, to bound the log. */ + private final java.util.concurrent.atomic.AtomicLong registryBindingLastShout = + new java.util.concurrent.atomic.AtomicLong(-1L); + + /** + * AERE OPTIUNI-URGENTA (2026-08-02): the operator has EXPLICITLY asked this node to run even + * though its Falcon registry does not satisfy the binding genesis requires. + * + *

WHY THIS EXISTS AT ALL. The A8 guard is a CONFIGURATION guard: it refuses to start a node + * whose registry file is not the one the chain names. That refusal is correct and it is + * fail-closed, and it also means a single bad registry file pushed to the fleet takes the fleet + * down and the only documented way back was to rebuild or to hand-edit a service unit at whatever + * hour it happened. This flag is the way back. It is read ONCE, in the constructor, from {@value + * #PROPERTY_REGISTRY_MISMATCH_ALLOW} or its environment form, which is where the command-line + * option {@code --Xaere-pq-registry-mismatch-allow} writes. + * + *

WHY IT IS NOT A QUIET ONE. A silent bypass is worse than no bypass, because a fleet can run + * for months on it and nobody finds out until the property it was supposed to guarantee is needed. + * So: a full-width banner at startup, and one complete ERROR line at EVERY height at which the + * bypass actually carries weight, for as long as the node runs. + */ + private final boolean registryMismatchAllow; + + /** + * True once the bypass has ACTUALLY suppressed a refusal, at startup or on the block path. Distinct + * from {@link #registryMismatchAllow}, which only says the operator armed it: a node whose registry + * is fine must not announce that it is running unverified. + */ + private volatile boolean registryOverrideEngaged; + + /** Highest height at which the unsafe-override announcement has already been made. */ + private final java.util.concurrent.atomic.AtomicLong registryOverrideLastShout = + new java.util.concurrent.atomic.AtomicLong(-1L); + + private volatile String manifestHash; + + // ---- STAGE-2 LATE-ANCHOR (contract-anchored) state ---- + /** Parsed-but-not-yet-activated manifest pubkeys (validator index -> Falcon-512 pubkey). */ + private final Map pendingLate; + /** Parsed-but-not-yet-activated validator addresses (validator index -> address). */ + private final Map pendingLateAddresses; + /** keccak256(canonical pending manifest), 64-hex no 0x; null when no late manifest configured. */ + private final String pendingLateHash; + /** The on-chain anchor CONTRACT address (0x-hex) whose slot 0 must equal pendingLateHash. */ + private final String anchorContractAddress; + + private volatile boolean lateActivated; + private volatile boolean lateFailed; + + // ---- AERE FIX-OPRIRE-CONSENS: SEAL-ATTACHMENT GATE ---- + // + // The halting bug this state exists to prevent: seal ATTACHMENT used to be gated on nothing but + // key presence. The moment an operator dropped a Falcon key onto one validator and restarted it, + // that node began emitting Falcon-carrying Commit messages. Every peer still running the stock + // binary decodes Commit with a STRICT rlpInput.leaveList(), threw MalformedRLPInputException, and + // DISCARDED the entire Commit - including the decisive ECDSA seal it carried. A rolling + // key-by-key restart therefore walks the fleet straight through a state where the stock nodes + // cannot see enough ECDSA commit seals to reach quorum. + // + // The gate makes that order impossible: attachment is OFF until an explicitly configured + // ACTIVATION HEIGHT is reached AND the anchored registry covers every current validator. Both are + // fleet-wide facts, identical on every node, so all nodes flip together at the same block. Key + // presence alone now does nothing at all. + + /** Minimum gap required between the attachment height and the blocking fork height. */ + private static final long DEFAULT_MIN_ATTACH_LEAD = 256L; + + /** + * Minimum validator-set size for which BLOCKING Falcon quorum may be armed. At N=7 the Falcon + * quorum (5 of 7) equals the ECDSA quorum, so the Falcon layer has ZERO extra fault margin: a + * single keyed validator whose Falcon key is faulty or whose process is down becomes a new + * liveness dependency and stops the chain. N>=9 restores a one-fault Falcon margin. + */ + private static final int MIN_BLOCKING_VALIDATORS = 9; + + // ---- AERE ARMING GUARD (2026-08-01): the CHAIN-RELATIVE half of the attachment guard ---- + // + // The constructor-time guards below can only check the configuration against ITSELF. They have no + // chain, so they cannot answer the one question that matters most: is the configured attachment + // height still AHEAD of us? Measured hole (run P1-PAST-ATTACH-RESTART): a fleet configured exactly + // per the documented activation order, with aere.falcon.attachBlock UNSET everywhere, emitted + // nothing for 60 s. One validator was then restarted with aere.falcon.attachBlock=5 while the + // chain head was 19. It started with no complaint and ARMED at block 21, and each of the four + // stock peers immediately began discarding its Commit messages. A procedure is not a guard. + // + // Two things must be true for the fleet to flip together at one block: + // (i) the height has not already gone by; and + // (ii) every node is already PARTICIPATING in consensus before that height arrives, otherwise a + // node that is still starting up arms at the first block it commits, which is a different + // block from the rest of the fleet. Measured on the scratch fleet: 45.2 s elapse between + // the startup config guard and the first commit this node takes part in. + // The margin below is what makes (ii) true, which is why it is a margin and not a mere > head. + + /** + * Default number of blocks by which {@code aere.falcon.attachBlock} must LEAD this node's local + * chain head at startup. Sized from the measured startup-to-first-commit window (45.2 s, i.e. ~46 + * blocks on the 1 s scratch fleet) with a wide safety factor, so that a node which is still + * opening its database, syncing and joining the network cannot have the activation height arrive + * underneath it. Chains with a sub-second block period must RAISE this: see + * GARDA-ACTIVARE-2026-08-01.md for the sizing formula. + */ + private static final long DEFAULT_MIN_ATTACH_FUTURE_MARGIN = 1024L; + + /** + * How many blocks back from the chain head the startup guard looks for positive proof that the + * fleet has ALREADY armed at the configured height (a header carrying at least one Falcon seal). + * Once the fleet arms, every subsequent block carries seals, so the evidence is at the head; the + * lookback only absorbs a short tail. + */ + private static final int ACTIVATION_EVIDENCE_LOOKBACK = 128; + + /** + * File written inside the node's data directory when the startup guard ADMITS an attachment height + * that was safely in the future. Its only job is to tell a later restart "this node was already + * admitted with this exact activation height, on this exact registry", so that a crash during an + * activation window is not turned into a permanent refusal to start. + */ + private static final String ADMISSION_RECEIPT_FILE = "aere-falcon-activation.receipt"; + + /** + * D-081: the registries this node holds for SCHEDULED HEIGHTS IT IS NO LONGER AT, as a + * comma-separated list of files. Env: {@code AERE_FALCON_REGISTRY_HISTORY}. + * + *

WHY A SECOND PROPERTY AND NOT A LIST IN THE FIRST. {@code aere.falcon.registry} names the + * registry this node SIGNS with, and there can only ever be one of those. This names the ones it + * must still be able to VERIFY against, and there is one per rotation the chain has ever done. + * Keeping them apart means a rotation adds a file to this list and changes the first property, and + * neither operation can be confused with the other. + * + *

The list is never pruned. Cosmos ADR-016 may bound its rotation history by the unbonding + * period; chain 2800 has no unbonding period and a node syncing from genesis verifies every block + * ever produced, so every registry ever scheduled has to stay here forever. + */ + public static final String PROPERTY_REGISTRY_HISTORY = "aere.falcon.registry.history"; + + /** + * AERE OPTIUNI-URGENTA: system property behind the command-line option {@code + * --Xaere-pq-registry-mismatch-allow}. Env: {@code AERE_PQ_REGISTRY_MISMATCH_ALLOW}. Setting it + * lets this node start and keep running with a Falcon registry that does NOT satisfy the binding + * genesis requires, at the price of one full ERROR line at every height where that matters. + */ + public static final String PROPERTY_REGISTRY_MISMATCH_ALLOW = "aere.pq.registry.mismatchAllow"; + + /** Environment-variable form of {@link #PROPERTY_REGISTRY_MISMATCH_ALLOW}. */ + public static final String ENV_REGISTRY_MISMATCH_ALLOW = "AERE_PQ_REGISTRY_MISMATCH_ALLOW"; + + /** + * AERE D-079: the DECLARED height at or after which the on-chain late-anchor registry contract is + * expected to be observable. Mandatory whenever a blocking fork height is armed over a late-anchor + * registry that is still pending; see {@link #validateAnchorObservationHeightOrAbort}. + */ + public static final String PROPERTY_ANCHOR_OBSERVE_BLOCK = "aere.falcon.anchor.block"; + + /** Environment-variable form of {@link #PROPERTY_ANCHOR_OBSERVE_BLOCK}. */ + public static final String ENV_ANCHOR_OBSERVE_BLOCK = "AERE_FALCON_ANCHOR_BLOCK"; + + /** Activation height for seal ATTACHMENT; Long.MAX_VALUE means NEVER attach. */ + private final long attachBlock; + + /** + * AERE D-079: the BLOCKING height, resolved and validated exactly ONCE, at construction, and + * owned thereafter. Long.MAX_VALUE means never blocking. + * + *

It used to be a property read on every call to {@link #forkBlock()}, with a + * NumberFormatException swallowed into "never blocking, log-only" plus one WARN line. The startup + * guard added for AUD-CONSENSUS-3 validated the property but did not TAKE it, so the one value the + * whole PQC layer is gated on could still turn itself off after the guard had passed, at any call, + * on any input the guard never saw. A value that is checked at the boundary and re-parsed at every + * use is not checked. It is parsed here, once, or the node does not start. + */ + private final long forkBlock; + + /** + * AERE D-079: the height at and after which the on-chain LATE-ANCHOR registry contract is expected + * to be observable, as DECLARED by the operator. Long.MAX_VALUE when undeclared. + * + *

This value does not activate anything. It exists because without it the ordering that the + * whole late-anchor activation depends on cannot be stated, and therefore cannot be checked: the + * registry becomes usable at a height that only the chain knows, while blocking arms at a height + * only the configuration knows. Declaring it is what lets the two be compared before the node + * joins the network. + */ + private final long anchorObserveBlock; + + /** + * AERE D-079: first height at which this node validated a header at or after the blocking height + * while the anchored registry was NOT active, or -1 if that has never happened. + * + *

This is the residual the configuration guard cannot close: an operator may declare the + * observation height correctly and the anchor transaction may still fail to land. The header rule + * deliberately stays LOG-ONLY there rather than halt the chain (see AUD-CONSENSUS-4), which is the + * right trade and is also exactly how the condition used to disappear: log-only, one line, nothing + * a command could ever read. Now it leaves a mark. + */ + private final AtomicLong blockingArmedWithoutRegistrySince = new AtomicLong(-1L); + + /** Outcome of the chain-relative startup guard; NOT_CHECKED until it has run. */ + private volatile ChainRelativeGateState chainRelativeState = ChainRelativeGateState.NOT_CHECKED; + + /** Height at which activation evidence was found on chain, or -1. */ + private volatile long armedEvidenceHeight = -1L; + + /** Latest validator set observed from the header-validation path, or null if never observed. */ + private volatile Set

observedValidators; + + /** Height at which {@link #observedValidators} was captured. */ + private volatile long observedValidatorsHeight = -1L; + + /** One-shot latches so the gate diagnostics do not spam one line per block. */ + private final AtomicBoolean loggedAttachmentOff = new AtomicBoolean(false); + + private final AtomicBoolean loggedAttachmentOn = new AtomicBoolean(false); + + private final AtomicBoolean loggedCoverageBlocked = new AtomicBoolean(false); + + /** + * D2 HARDENING (b): the once-only latch for the refusal to resolve keys at an armed height with no + * height-to-registry binding. One line per block at a 523 ms period is a hazard, not a diagnostic. + */ + private final AtomicBoolean loggedUnboundArmedHeight = new AtomicBoolean(false); + + /** + * AERE D-078 (2026-08-02): the last coverage situation this node reported, as "N/R", so the + * coverage line is emitted exactly when the situation CHANGES and not once per block. Null means + * nothing has been reported yet. + */ + private volatile String lastCoverageSignature; + + private FalconSealSupport() { + int idx = -1; + FalconPrivateKeyParameters priv = null; + final Map reg = new ConcurrentHashMap<>(); + final Map regAddr = new ConcurrentHashMap<>(); + String mHash = null; + boolean anchored = false; + Map late = null; + Map lateAddr = null; + String lateHash = null; + String anchorAddr = null; + + // Registry source resolution order: + // 1) GENESIS-ANCHORED manifest (aere.falcon.genesis) - tamper-evident, immutable, preferred. + // 2) LATE-ANCHOR manifest + contract (aere.falcon.manifest + aere.falcon.anchor.address) - + // Stage-2 activation path for an ALREADY-LIVE chain: pending until the anchor contract is + // observed on-chain and the manifest hash matches (fail-closed). + // 3) LEGACY shared .properties file (aere.falcon.registry) - backward-compatible fallback. + final String genesisPath = resolve("aere.falcon.genesis", "AERE_FALCON_GENESIS"); + final String manifestPath = resolve("aere.falcon.manifest", "AERE_FALCON_MANIFEST"); + final String anchorProp = resolve("aere.falcon.anchor.address", "AERE_FALCON_ANCHOR_ADDRESS"); + final String registryPath = resolve("aere.falcon.registry", "AERE_FALCON_REGISTRY"); + if (genesisPath != null) { + mHash = loadGenesisAnchoredRegistry(genesisPath, reg, regAddr); + anchored = mHash != null; + } else if (manifestPath != null && anchorProp != null) { + final Map pend = new HashMap<>(); + final Map pendAddr = new HashMap<>(); + lateHash = loadPendingLateManifest(manifestPath, pend, pendAddr); + if (lateHash != null) { + late = pend; + lateAddr = pendAddr; + anchorAddr = anchorProp.trim(); + LOG.info( + "AERE PQC: LATE-ANCHOR manifest loaded from {} ({} entries, address-bound={}), " + + "keccak256(manifest)=0x{}; registry PENDING until the on-chain anchor contract " + + "{} (slot 0) is observed to match. Fail-closed on mismatch.", + manifestPath, + pend.size(), + !pendAddr.isEmpty() && pendAddr.size() == pend.size(), + lateHash, + anchorAddr); + } else { + LOG.error( + "AERE PQC: failed to load LATE-ANCHOR manifest from {}; registry EMPTY (fail-closed).", + manifestPath); + } + } else if (registryPath != null) { + // AERE A8 REPAIR (2026-08-02): the properties source the design prescribed was measured to + // HALT EVERY NODE at the activation height. Not by being mutable, and not by disagreeing with + // a hash: by carrying public keys and NO ADDRESSES. addressForIndex() then returns null for + // every index, and the V2 seals rule (R2) refuses every header that carries a certificate. + // Measured on three observers with the data directory wiped, on the SAME honest chain: + // genesis-anchored address-bound registry reached head 374 with R1=0 R2=0, while this source + // stopped at head 19 with R2=4 rejections, indistinguishable from having no registry at all. + // + // The repair is in two halves and both are needed. FIRST, this loader now reads the optional + // ".addr=0x<20 bytes>" row, exactly the shape PqRegistryHash.loadPropertiesRegistry + // already accepts, so the prescribed source CAN be address-bound and CAN carry the fleet past + // activation. SECOND, armingReadinessDiagnostic() below no longer merely logs when an + // activation is configured over a registry that is still pubkey-only: it refuses to start. + // A loud refusal at startup and a silent fleet-wide halt at the activation height are not the + // same event, and only one of them is survivable. + try (InputStream in = new FileInputStream(registryPath)) { + final Properties p = new Properties(); + p.load(in); + final Map propAddr = new java.util.TreeMap<>(); + for (final String name : p.stringPropertyNames()) { + if ("count".equals(name)) { + continue; + } + try { + final String key = name.trim(); + if (key.endsWith(".addr")) { + final int i = Integer.parseInt(key.substring(0, key.length() - ".addr".length())); + final byte[] a = Bytes.fromHexStringLenient(p.getProperty(name).trim()).toArray(); + if (a.length != 20) { + throw new IllegalArgumentException( + "address is " + a.length + " bytes, expected exactly 20"); + } + // Built through the canonical hex of the VALIDATED 20 bytes, not from the raw string + // and not through Address.wrap: the lenient hex parser above accepts shapes the + // strict one does not, so re-rendering the checked bytes is the only spelling that + // cannot disagree with the length check that just passed. + propAddr.put(i, Address.fromHexString(Bytes.wrap(a).toHexString())); + } else { + final int i = Integer.parseInt(key); + final byte[] h = Bytes.fromHexStringLenient(p.getProperty(name).trim()).toArray(); + reg.put(i, new FalconPublicKeyParameters(FalconParameters.falcon_512, h)); + } + } catch (final RuntimeException e) { + LOG.warn("Skipping malformed Falcon registry entry '{}': {}", name, e.toString()); + } + } + // Address binding is ALL-OR-NOTHING. A half-bound registry is worse than an unbound one: + // some indices would resolve and some would not, so the fleet would reject a header that + // one operator can verify and another cannot, which is defect A8 wearing a different hat. + final boolean fullyBound = !reg.isEmpty() && propAddr.keySet().equals(reg.keySet()); + if (fullyBound) { + regAddr.putAll(propAddr); + } else if (!propAddr.isEmpty()) { + LOG.error( + "AERE PQC A8: LEGACY registry file {} carries {} '.addr' rows for {} keys. A " + + "PARTIALLY address-bound registry is refused as a binding source (all-or-" + + "nothing): some indices would resolve to a validator address and some would " + + "not, so two nodes would disagree about which headers are valid. Treating this " + + "registry as pubkey-only.", + registryPath, + propAddr.size(), + reg.size()); + } + LOG.info( + "AERE PQC: loaded Falcon validator registry ({} entries, address-bound={}) from LEGACY " + + "file {} (mutable; superseded by aere.falcon.genesis / aere.falcon.manifest). " + + "{}", + reg.size(), + fullyBound, + registryPath, + fullyBound + ? "Address-bound, so the eligible-signer intersection is computable and this " + + "registry CAN carry the fleet past an activation height; it is still bound to " + + "consensus only if config.pqRegistryHash names its hash." + : "PUBKEY-ONLY, so addressForIndex() returns null for every index and EVERY header " + + "carrying a certificate would be rejected at the activation height. NOT " + + "eligible to arm blocking."); + } catch (final Exception e) { + LOG.warn( + "AERE PQC: failed to load Falcon registry from {}: {}", registryPath, e.toString()); + } + } else { + LOG.info( + "AERE PQC: no Falcon registry configured (aere.falcon.genesis/aere.falcon.manifest/" + + "aere.falcon.registry); hybrid seal verification will be a no-op."); + } + + // Load this node's Falcon private key (only present on validators that should sign). + final String keyPath = resolve("aere.falcon.key", "AERE_FALCON_KEY"); + if (keyPath != null) { + try (InputStream in = new FileInputStream(keyPath)) { + final Properties p = new Properties(); + p.load(in); + idx = Integer.parseInt(p.getProperty("index").trim()); + final byte[] f = Bytes.fromHexStringLenient(p.getProperty("f").trim()).toArray(); + final byte[] g = Bytes.fromHexStringLenient(p.getProperty("g").trim()).toArray(); + final byte[] bigF = Bytes.fromHexStringLenient(p.getProperty("F").trim()).toArray(); + final byte[] pk = Bytes.fromHexStringLenient(p.getProperty("pk").trim()).toArray(); + priv = new FalconPrivateKeyParameters(FalconParameters.falcon_512, f, g, bigF, pk); + LOG.info( + "AERE PQC: loaded local Falcon signing key for validator index {} from {}", + idx, + keyPath); + } catch (final Exception e) { + LOG.warn("AERE PQC: failed to load Falcon signing key from {}: {}", keyPath, e.toString()); + idx = -1; + priv = null; + } + } + + this.registry = reg; + this.indexToAddress = regAddr; + this.localIndex = idx; + this.localPrivateKey = priv; + this.signingEnabled = priv != null && idx >= 0; + this.manifestHash = mHash; + this.genesisAnchored = anchored; + this.pendingLate = late; + this.pendingLateAddresses = lateAddr; + this.pendingLateHash = lateHash; + this.anchorContractAddress = anchorAddr; + + // AERE A8: remember WHICH file the registry came from, so the consensus-binding guard hashes + // the file this node is really running on and not a different one that happens to be nearby. + if (genesisPath != null) { + this.registrySourcePath = genesisPath; + this.registrySourceKind = PqRegistryHash.SourceKind.GENESIS_MANIFEST; + } else if (manifestPath != null && anchorProp != null) { + this.registrySourcePath = manifestPath; + this.registrySourceKind = PqRegistryHash.SourceKind.MANIFEST_JSON; + } else if (registryPath != null) { + this.registrySourcePath = registryPath; + this.registrySourceKind = PqRegistryHash.SourceKind.LEGACY_PROPERTIES; + } else { + this.registrySourcePath = null; + this.registrySourceKind = PqRegistryHash.SourceKind.NONE; + } + + // AERE OPTIUNI-URGENTA (2026-08-02): read the registry bypass ONCE, here, so that every later + // decision reads one immutable field rather than re-resolving a property that could have been + // changed underneath it. Announced immediately, whether or not it ends up carrying weight, + // because "somebody armed the bypass on this node" is itself something an operator must be able + // to see in the first screen of the log. + this.registryMismatchAllow = + Boolean.parseBoolean( + resolve(PROPERTY_REGISTRY_MISMATCH_ALLOW, ENV_REGISTRY_MISMATCH_ALLOW)); + if (this.registryMismatchAllow) { + LOG.warn( + "AERE PQC EMERGENCY [AERE-PQC-EMG-REGALLOW-01]: the Falcon registry binding bypass is " + + "ARMED on this node ({}). If the registry this node holds does NOT satisfy what " + + "genesis requires, this node will START ANYWAY and will keep importing headers " + + "whose certificates it cannot correctly verify, and it will say so at every height " + + "where that is true. This is a RECOVERY setting. It is not a configuration to leave " + + "in place: remove it as soon as the correct registry is installed.", + PROPERTY_REGISTRY_MISMATCH_ALLOW); + } + + // AERE audit fix (AUD-CONSENSUS-3): fail-closed on a MALFORMED fork-block config at startup, + // before anything else. A present-but-unparseable aere.falcon.forkBlock must not silently + // degrade to never-blocking (log-only); it aborts node init here (config time, NOT the per-block + // consensus path). + // AERE D-079: the result is TAKEN, not merely checked. Nothing re-reads the property afterwards. + this.forkBlock = validateForkBlockConfigOrAbort(); + + // AERE FIX-OPRIRE-CONSENS (b): resolve and validate the ATTACHMENT gate. Every inconsistent + // combination aborts here, at config time, before the node joins the network - never as a + // surprise mid-chain. + this.attachBlock = validateAndResolveAttachBlockOrAbort(this.forkBlock); + + // AERE D-079: the ORDER between the blocking height and the height at which the registry that + // backs it can become active. Aborts here, at config time, for the same reason as everything + // above it: after the node has joined, the same error is a silent degradation to log-only. + this.anchorObserveBlock = + validateAnchorObservationHeightOrAbort(this.forkBlock, this.attachBlock); + + // AERE audit fix (AUD-CONSENSUS-1): fail-closed ARMING diagnostic at config/startup time. + // If a fork block is configured (blocking is intended) but the manifest that WILL back it is + // not address-bound, the eligible-signer intersection cannot be formed and every post-fork + // block will fail closed. Surface that LOUDLY here, at arm/config time, rather than as a silent + // block-production halt later. (Late-anchor: the pending manifest is checked; the live registry + // is still empty until activation, which is expected.) + armingReadinessDiagnostic(); + + // AERE D-146 (2026-08-06): the SECOND arming question, and the one AERE-PQC-REG-ARM-01 cannot + // answer. Address-bound says every index has SOME address next to it. It does not say that the + // validator at that address ever held the Falcon key filed under it. Measured on the real + // verification path on 2026-08-06: a registry with two rows' public keys swapped - no duplicate + // key, no duplicate address, so no uniqueness check would have seen it - produced an ACCEPTED + // header, and one key placed at two indices satisfied a threshold of two on its own. + requireRegistryBindingProofsOrAbort(); + + // AERE D-078 (2026-08-03): the arm-time comparison the repair above NAMED and did not make. + // armingReadinessDiagnostic() answers "is the manifest address-bound"; it never asks whether the + // threshold the fleet is about to arm is one the fleet can be guaranteed to MEET. + validateThresholdReachabilityOrAbort(); + } + + /** + * AERE audit fix (AUD-CONSENSUS-3): distinguish an UNSET fork block from a MALFORMED configured + * one. {@link #forkBlock()} on the per-block path deliberately never throws (it falls back to + * {@link Long#MAX_VALUE}); on its own that means a typo'd fork block fails OPEN to never-blocking, + * asymmetric with the manifest / anchor loaders that all fail CLOSED. This startup guard closes + * that gap: if the property/env IS present but does not parse as a NON-NEGATIVE block number, we + * ABORT node init (fail-closed). This runs at construction (node init), not during block + * validation, so it never throws on the consensus path. An UNSET value keeps the safe + * never-blocking log-only default. + * + *

AERE D-079 (2026-08-03): it now RETURNS the validated height and the constructor keeps it in + * a final field. Validating a value and then re-reading its source on every use leaves the + * original defect intact one level down, which is exactly where it was found. + * + * @return the validated blocking height, or {@link Long#MAX_VALUE} when unset + */ + private long validateForkBlockConfigOrAbort() { + final String v = resolve("aere.falcon.forkBlock", "AERE_FALCON_FORKBLOCK"); + if (v == null) { + return Long.MAX_VALUE; // unset: safe never-blocking default, nothing to validate + } + final long parsed; + try { + parsed = Long.parseLong(v.trim()); + } catch (final NumberFormatException e) { + throw new ActivationConfigException( + ActivationConfigException.Kind.SYNTAX, + "AERE-PQC-CFG-SYNTAX-03", + "AERE PQC: aere.falcon.forkBlock is set to a MALFORMED value '" + + v + + "' (not an integer block number). Refusing to start (fail-closed): a malformed fork " + + "block must not silently degrade to never-blocking / log-only. Set a valid " + + "non-negative block number, or UNSET the property to run the additive log-only " + + "baseline."); + } + if (parsed < 0) { + throw new ActivationConfigException( + ActivationConfigException.Kind.SYNTAX, + "AERE-PQC-CFG-SYNTAX-04", + "AERE PQC: aere.falcon.forkBlock '" + + v + + "' is negative. Refusing to start (fail-closed): set a valid non-negative block " + + "number, or UNSET the property to run the additive log-only baseline."); + } + return parsed; + } + + /** + * AERE D-079: the ordering guard between the BLOCKING height and the height at which the registry + * that backs it can first be active. + * + *

THE HOLE THIS CLOSES, in the words of the code that documented it and did nothing about it. + * {@code FalconSealValidationRule} says: "If forkBlock is armed at or before the late-anchor + * observation height, the registry is not yet active when the fork block is validated". Its answer + * is to stay LOG-ONLY, which is the correct answer for a header rule - blocking over an empty + * registry provides zero safety and only a halt. But it means the misconfiguration has no + * consequence a node can be stopped by and no consequence a command can read: the operator asked + * for a post-quantum quorum, the node said yes, and the quorum is never enforced. + * + *

The check cannot be made against the chain, because the height at which an anchor contract + * lands is not knowable at config time. It is therefore made against a DECLARED height, {@code + * aere.falcon.anchor.block}, in the same spirit as EIP-7910's {@code eth_config}: state the fork + * parameters explicitly so they can be compared, instead of inferring them and finding out at the + * boundary. Declaring the height is mandatory whenever blocking is armed over a registry that is + * still pending, because a value nobody stated is a value nobody can check. + * + *

Only two rules are needed, and the second one implies what the finding is named after: + * + *

    + *
  1. a blocking height over a PENDING late anchor with no declared observation height aborts; + *
  2. {@code attachBlock < anchorObserveBlock} aborts. Because {@code forkBlock - attachBlock >= + * minAttachLead} is already enforced above, this makes {@code forkBlock > anchorObserveBlock + * + minAttachLead} a THEOREM rather than a third rule that could drift out of step with the + * other two. + *
+ * + *

Genesis-anchored and legacy-file registries are exempt on purpose: both are active from block + * 0, so there is no observation height to compare against and demanding one would break the + * deployment path that {@link #armingReadinessDiagnostic()} names as the remedy. + * + * @param fork the validated blocking height + * @param attach the validated attachment height + * @return the declared observation height, or {@link Long#MAX_VALUE} when undeclared + */ + private long validateAnchorObservationHeightOrAbort(final long fork, final long attach) { + final String raw = resolve(PROPERTY_ANCHOR_OBSERVE_BLOCK, ENV_ANCHOR_OBSERVE_BLOCK); + final long observe; + if (raw == null) { + observe = Long.MAX_VALUE; + } else { + final long parsed; + try { + parsed = Long.parseLong(raw.trim()); + } catch (final NumberFormatException e) { + throw new ActivationConfigException( + ActivationConfigException.Kind.SYNTAX, + "AERE-PQC-CFG-SYNTAX-09", + "AERE PQC: " + + PROPERTY_ANCHOR_OBSERVE_BLOCK + + " is set to a MALFORMED value '" + + raw + + "' (not an integer block number). Refusing to start (fail-closed): every " + + "aere.falcon.* height fails closed on a syntax error, uniformly, because a " + + "half-fail-closed property set is worse than either extreme - nobody can predict " + + "which half they are in."); + } + if (parsed < 0) { + throw new ActivationConfigException( + ActivationConfigException.Kind.SYNTAX, + "AERE-PQC-CFG-SYNTAX-10", + "AERE PQC: " + + PROPERTY_ANCHOR_OBSERVE_BLOCK + + " '" + + raw + + "' is negative. Refusing to start (fail-closed)."); + } + observe = parsed; + } + + // Nothing blocking is configured, or the registry is already active at block 0. In both cases + // there is no ordering to violate. + if (fork == Long.MAX_VALUE || genesisAnchored || anchorContractAddress == null) { + return observe; + } + + if (raw == null) { + throw new ActivationConfigException( + ActivationConfigException.Kind.UNSAFE, + "AERE-PQC-CFG-UNSAFE-06", + "AERE PQC: aere.falcon.forkBlock is set to " + + fork + + " (Falcon quorum BLOCKING) over a LATE-ANCHOR registry that is still PENDING (anchor " + + "contract " + + anchorContractAddress + + "), and " + + PROPERTY_ANCHOR_OBSERVE_BLOCK + + " is UNDECLARED. Refusing to start (fail-closed). WHY THIS IS REFUSED RATHER THAN " + + "WARNED: if the anchor is observed at or after the blocking height, the registry is " + + "empty when the fork block is validated, and the header rule then stays LOG-ONLY " + + "forever - correctly, because blocking over an empty registry would only halt the " + + "chain. The result is a node that was told to enforce a post-quantum quorum, " + + "reported no error, and enforces nothing. Nothing on the node can detect that, " + + "because the height the registry becomes usable at is known only to the chain and " + + "the height blocking arms at is known only to this configuration. Declare " + + PROPERTY_ANCHOR_OBSERVE_BLOCK + + " as the height at or after which the anchor contract is expected to be observable, " + + "and set aere.falcon.attachBlock at or after it."); + } + + if (attach < observe) { + throw new ActivationConfigException( + ActivationConfigException.Kind.UNSAFE, + "AERE-PQC-CFG-UNSAFE-07", + "AERE PQC: aere.falcon.attachBlock (" + + attach + + ") is BEFORE " + + PROPERTY_ANCHOR_OBSERVE_BLOCK + + " (" + + observe + + "), with aere.falcon.forkBlock=" + + fork + + ". Refusing to start (fail-closed): until the anchor is observed the registry is " + + "empty, so no node can emit a Falcon seal, so the LOG-ONLY soak window between " + + "attachment and blocking observes nothing and proves nothing. Order the three " + + "heights " + + PROPERTY_ANCHOR_OBSERVE_BLOCK + + " <= aere.falcon.attachBlock, and aere.falcon.forkBlock at least " + + minAttachLead() + + " blocks after aere.falcon.attachBlock; that ordering is also the only thing that " + + "keeps the blocking height strictly AFTER the observation height, which is the " + + "configuration this guard exists to refuse."); + } + + LOG.info( + "AERE PQC: activation ORDER accepted: {}={} <= attachBlock={}, forkBlock={} (lead {} >= " + + "{}). Blocking arms {} block(s) after the earliest height at which the anchored " + + "registry can be active.", + PROPERTY_ANCHOR_OBSERVE_BLOCK, + observe, + attach, + fork, + fork - attach, + minAttachLead(), + fork - observe); + return observe; + } + + /** + * AERE FIX-OPRIRE-CONSENS (b): resolve and validate the seal-ATTACHMENT height. Runs at + * construction (node init), never on the consensus path. Every inconsistent combination ABORTS + * here, so a fleet can never be started into a configuration that halts it later. + * + *

Rules enforced: + * + *

    + *
  1. a present but MALFORMED or negative attachBlock aborts (no silent degrade); + *
  2. configuring BLOCKING ({@code aere.falcon.forkBlock}) without an attachBlock aborts: seals + * would never be emitted, so every post-fork block would fail closed and the chain would + * stop; + *
  3. {@code attachBlock > forkBlock} aborts: blocking would arm before any seal exists; + *
  4. {@code forkBlock - attachBlock} below the minimum lead aborts: the fleet needs a + * log-only window in which seals are demonstrably flowing and verifying before the quorum + * becomes decisive; + *
  5. arming BLOCKING with a validator set smaller than {@link #MIN_BLOCKING_VALIDATORS} + * aborts, because at N<9 the Falcon quorum has no fault margin over the ECDSA quorum + * (see the N=7 zero-margin finding). Isolated test networks opt out explicitly with + * {@code aere.falcon.testnetAllowSmallFleet=true}, which logs an ERROR every start. + *
+ * + * @param fork the already-validated blocking height (AERE D-079: passed in rather than re-parsed + * from the property, so this method and {@link #forkBlock()} cannot disagree about it) + * @return the resolved attachment height, or {@link Long#MAX_VALUE} when unset + */ + private long validateAndResolveAttachBlockOrAbort(final long fork) { + final String rawAttach = resolve("aere.falcon.attachBlock", "AERE_FALCON_ATTACHBLOCK"); + + if (rawAttach == null) { + if (fork == Long.MAX_VALUE && signingEnabled) { + // The rolling-restart case: an operator dropped a Falcon key on this node and restarted it. + // Say plainly, at startup, that this does NOT arm anything. + LOG.warn( + "AERE PQC: this node HOLDS a Falcon signing key (index {}) but seal ATTACHMENT is NOT " + + "configured (aere.falcon.attachBlock unset), so NO Falcon-carrying Commit will be " + + "emitted. This is the safe default: emitting one while any peer still runs a " + + "binary with a strict Commit decoder would make that peer DISCARD the whole " + + "Commit, ECDSA seal included.", + localIndex); + } + if (fork != Long.MAX_VALUE) { + throw new ActivationConfigException( + ActivationConfigException.Kind.UNSAFE, + "AERE-PQC-CFG-UNSAFE-01", + "AERE PQC: aere.falcon.forkBlock is set to " + + fork + + " (Falcon quorum BLOCKING) but aere.falcon.attachBlock is UNSET. Refusing to " + + "start (fail-closed): with attachment off, no node ever emits a Falcon seal, so " + + "every block at or after the fork block would be rejected for want of a quorum " + + "certificate and the chain would STOP. Set aere.falcon.attachBlock to a height at " + + "least " + + minAttachLead() + + " blocks BEFORE the fork block, and only after the whole fleet runs a binary that " + + "can parse a Falcon-carrying Commit."); + } + return Long.MAX_VALUE; + } + + final long attach; + try { + attach = Long.parseLong(rawAttach.trim()); + } catch (final NumberFormatException e) { + throw new ActivationConfigException( + ActivationConfigException.Kind.SYNTAX, + "AERE-PQC-CFG-SYNTAX-01", + "AERE PQC: aere.falcon.attachBlock is set to a MALFORMED value '" + + rawAttach + + "' (not an integer block number). Refusing to start (fail-closed)."); + } + if (attach < 0) { + throw new ActivationConfigException( + ActivationConfigException.Kind.SYNTAX, + "AERE-PQC-CFG-SYNTAX-02", + "AERE PQC: aere.falcon.attachBlock '" + rawAttach + "' is negative. Refusing to start."); + } + + if (fork != Long.MAX_VALUE) { + if (attach > fork) { + throw new ActivationConfigException( + ActivationConfigException.Kind.UNSAFE, + "AERE-PQC-CFG-UNSAFE-02", + "AERE PQC: aere.falcon.attachBlock (" + + attach + + ") is AFTER aere.falcon.forkBlock (" + + fork + + "). Refusing to start (fail-closed): the Falcon quorum would become blocking " + + "before any Falcon seal is ever attached, rejecting every block."); + } + final long lead = fork - attach; + if (lead < minAttachLead()) { + throw new ActivationConfigException( + ActivationConfigException.Kind.UNSAFE, + "AERE-PQC-CFG-UNSAFE-03", + "AERE PQC: only " + + lead + + " block(s) between aere.falcon.attachBlock (" + + attach + + ") and aere.falcon.forkBlock (" + + fork + + "); the minimum lead is " + + minAttachLead() + + ". Refusing to start (fail-closed): the fleet needs a LOG-ONLY window in which " + + "seals are observably flowing and verifying before the quorum becomes decisive."); + } + final int fleet = expectedFleetSize(); + final boolean smallFleetAllowed = + Boolean.parseBoolean( + String.valueOf( + resolve("aere.falcon.testnetAllowSmallFleet", "AERE_FALCON_TESTNET_SMALL_FLEET"))); + if (fleet < MIN_BLOCKING_VALIDATORS && !smallFleetAllowed) { + throw new ActivationConfigException( + ActivationConfigException.Kind.UNSAFE, + "AERE-PQC-CFG-UNSAFE-04", + "AERE PQC: BLOCKING Falcon quorum is configured (forkBlock=" + + fork + + ") for a fleet of " + + fleet + + " validator(s), below the minimum of " + + MIN_BLOCKING_VALIDATORS + + ". Refusing to start (fail-closed): at N<" + + MIN_BLOCKING_VALIDATORS + + " the Falcon quorum equals the ECDSA quorum, so the Falcon layer carries ZERO " + + "extra fault margin and any single keyed validator that is down or holds a faulty " + + "Falcon key becomes a new liveness dependency that stops the chain. Grow the " + + "validator set to at least " + + MIN_BLOCKING_VALIDATORS + + " first. Isolated test networks may set aere.falcon.testnetAllowSmallFleet=true."); + } + if (fleet < MIN_BLOCKING_VALIDATORS) { + LOG.error( + "AERE PQC: aere.falcon.testnetAllowSmallFleet=true - BLOCKING armed on a fleet of {} " + + "(< {}). ZERO Falcon fault margin. This setting MUST NOT be used on mainnet.", + fleet, + MIN_BLOCKING_VALIDATORS); + } + } + + LOG.info( + "AERE PQC: seal-ATTACHMENT gate configured: attachBlock={}, forkBlock={}, minLead={}. " + + "Holding a Falcon key does NOT by itself emit a Falcon-carrying Commit.", + attach, + fork == Long.MAX_VALUE ? "unset" : Long.toString(fork), + minAttachLead()); + return attach; + } + + /** + * Minimum number of blocks between the attachment height and the blocking fork height. + * + *

AERE ARMING GUARD: this used to swallow a malformed value and silently return the default. + * That is the same silent-degrade defect the attachBlock guard exists to prevent, one level down: + * an operator who typed the margin wrong would believe a long soak window was enforced when the + * default was. Every {@code aere.falcon.*} value now fails closed on a syntax error, uniformly. A + * half-fail-closed property set is worse than either extreme, because nobody can predict which + * half they are in. + */ + private long minAttachLead() { + final String v = resolve("aere.falcon.minAttachLead", "AERE_FALCON_MIN_ATTACH_LEAD"); + if (v == null) { + return DEFAULT_MIN_ATTACH_LEAD; + } + final long parsed; + try { + parsed = Long.parseLong(v.trim()); + } catch (final NumberFormatException e) { + throw new ActivationConfigException( + ActivationConfigException.Kind.SYNTAX, + "AERE-PQC-CFG-SYNTAX-07", + "AERE PQC: aere.falcon.minAttachLead is set to a MALFORMED value '" + + v + + "' (not an integer number of blocks). Refusing to start: a soak-window length that " + + "silently reverted to its default would leave the operator believing a longer soak " + + "is enforced than actually is."); + } + if (parsed < 0) { + throw new ActivationConfigException( + ActivationConfigException.Kind.SYNTAX, + "AERE-PQC-CFG-SYNTAX-07", + "AERE PQC: aere.falcon.minAttachLead '" + v + "' is negative. Refusing to start."); + } + return parsed; + } + + /** + * Best available estimate of the validator-set size at config time, used only for the N>=9 + * blocking guard. The registry (or the pending late manifest) is address-bound one entry per + * validator, so its size is the fleet the operator intends to arm. + */ + private int expectedFleetSize() { + final String v = resolve("aere.falcon.validatorCount", "AERE_FALCON_VALIDATOR_COUNT"); + if (v != null) { + try { + return Integer.parseInt(v.trim()); + } catch (final NumberFormatException e) { + throw new ActivationConfigException( + ActivationConfigException.Kind.SYNTAX, + "AERE-PQC-CFG-SYNTAX-08", + "AERE PQC: aere.falcon.validatorCount is set to a MALFORMED value '" + + v + + "' (not an integer). Refusing to start: falling through to the registry-derived " + + "size would silently apply the N>=9 blocking guard to a fleet size the operator " + + "did not state."); + } + } + if (!registry.isEmpty()) { + return registry.size(); + } + return pendingLate == null ? 0 : pendingLate.size(); + } + + // ---- AERE ARMING GUARD (2026-08-01): CHAIN-RELATIVE STARTUP GUARD ---- + + /** Outcome of the chain-relative attachment-height guard. Exposed for tests and diagnostics. */ + public enum ChainRelativeGateState { + /** The guard has not run yet. */ + NOT_CHECKED, + /** No attachment height is configured, so there is nothing to check. */ + NOT_APPLICABLE, + /** The attachment height leads the local chain head by at least the required margin. */ + FUTURE, + /** The height has passed and the chain itself proves the fleet armed at it. */ + RESUMED_ACTIVATED, + /** This node was previously admitted with this exact height while it was still in the future. */ + RESUMED_ADMITTED, + /** An operator named this exact height in an explicit acknowledgement property. */ + OPERATOR_ACKNOWLEDGED + } + + /** + * Thrown to ABORT node startup on a Falcon activation configuration that must not be allowed to + * run. The {@link Kind} separates the two cases an operator has to treat differently. + * + *

A {@link Kind#SYNTAX} failure is a value this code could not parse at all. It can never be + * correct anywhere, it is local to this node, and the fix is to correct the string on this node + * and restart. A {@link Kind#UNSAFE} failure is a value that parsed perfectly and describes a + * configuration that would damage the fleet. It is usually NOT local: the same value is probably + * on the other validators too, and "fixing" it on this node alone can be exactly the wrong move. + * Keeping the two apart is the difference between a one-node typo and a fleet-wide decision. + */ + public static final class ActivationConfigException extends IllegalStateException { + + private static final long serialVersionUID = 1L; + + /** Whether the value could not be parsed, or parsed and is dangerous. */ + public enum Kind { + /** The configured value is not parseable. A typo. Local to this node. */ + SYNTAX, + /** The configured value parses but the resulting activation would be unsafe. */ + UNSAFE + } + + private final Kind kind; + private final String code; + + ActivationConfigException(final Kind kind, final String code, final String message) { + super("[" + code + "] " + message); + this.kind = kind; + this.code = code; + } + + /** + * The failure class. + * + * @return SYNTAX for an unparseable value, UNSAFE for a dangerous one + */ + public Kind kind() { + return kind; + } + + /** + * The stable machine-readable code, e.g. {@code AERE-PQC-CFG-UNSAFE-05}. + * + * @return the code + */ + public String code() { + return code; + } + } + + /** + * CHAIN-RELATIVE half of the seal-attachment guard: refuse to start when {@code + * aere.falcon.attachBlock} is not comfortably AHEAD of this node's chain head, unless there is + * positive evidence that this is a legitimate resumption rather than a misconfiguration. + * + *

Call this exactly once during node startup, from the first point at which a chain head + * exists and BEFORE the network and the consensus state machine are started, so that a refusal is + * a clean refusal to start rather than a mid-flight abort. + * + *

The hole this closes, measured: {@link #attachmentArmed(long)} only ever required {@code + * blockNumber >= attachBlock}. Nothing required {@code attachBlock} to be in the future, so a + * single validator restarted with an already-passed height began emitting Falcon-carrying Commit + * messages on its first commit, into a fleet that was otherwise correctly configured. + * + *

The failure mode this guard introduces, and how it is handled: a node that CRASHES and + * restarts after the attachment height has legitimately passed must still be able to come back. A + * guard that refuses that restart is itself a halt risk, and on the scratch fleet losing one of + * seven validators stalled the chain for 61 s. Three independent pieces of evidence therefore + * distinguish "this height was configured in the past, which is a misconfiguration" from "this + * height passed normally while we were running, which is expected": + * + *

    + *
  1. ON-CHAIN, strongest and fleet-wide: a header at or after the attachment height carries a + * Falcon seal. The fleet demonstrably armed. Nothing local is trusted for this. + *
  2. LOCAL ADMISSION RECEIPT: this node was already admitted, by this same guard, with this + * exact height and this exact registry, at a time when the height was safely in the future. + * This is what rescues a crash in the window BEFORE the height arrives, where no on-chain + * evidence can exist yet. + *
  3. EXPLICIT OPERATOR ACKNOWLEDGEMENT naming the exact height. The escape hatch for a node + * restored from a backup that predates the activation and lost its receipt. + *
+ * + * @param chainHeadNumber this node's local chain head at startup + * @param headerCarriesFalconSeal true iff the header at that height carries >= 1 Falcon seal; may + * be null, in which case on-chain evidence is treated as unavailable (fail-closed) + * @param dataDirectory the node data directory in which the admission receipt lives; may be null + * @throws ActivationConfigException with {@link ActivationConfigException.Kind#UNSAFE} when the + * configured height is not safely ahead and no evidence of a legitimate activation exists + */ + public void verifyAttachHeightAgainstChainHeadOrAbort( + final long chainHeadNumber, + final LongPredicate headerCarriesFalconSeal, + final Path dataDirectory) { + + if (attachBlock == Long.MAX_VALUE) { + chainRelativeState = ChainRelativeGateState.NOT_APPLICABLE; + LOG.info( + "AERE PQC: chain-relative attachment guard NOT APPLICABLE - aere.falcon.attachBlock is " + + "unset, so seal attachment is OFF at every height (safe default)."); + return; + } + + final long margin = minAttachFutureMargin(); + final long head = Math.max(0L, chainHeadNumber); + final long lead = attachBlock - head; + + if (lead >= margin) { + chainRelativeState = ChainRelativeGateState.FUTURE; + LOG.info( + "AERE PQC: attachment-height guard PASSED - aere.falcon.attachBlock={} leads this node's " + + "chain head {} by {} block(s); required margin aere.falcon.minAttachFutureMargin={}." + + " Recording an admission receipt so that a crash-restart inside the activation " + + "window is not refused.", + attachBlock, + head, + lead, + margin); + writeAdmissionReceipt(dataDirectory, head); + return; + } + + // The height is at, behind, or uncomfortably close to the head. Refuse UNLESS something + // positively proves this is a resumption. Absence of evidence is treated as evidence of + // misconfiguration: that direction is the fail-closed one. + + final long evidenceFloor = Math.max(attachBlock, head - ACTIVATION_EVIDENCE_LOOKBACK + 1); + if (fleetAlreadyArmed(head, evidenceFloor, headerCarriesFalconSeal)) { + chainRelativeState = ChainRelativeGateState.RESUMED_ACTIVATED; + LOG.warn( + "AERE PQC: attachment height {} is already PASSED at this node's chain head {} (lead={}, " + + "margin={}), but block {} carries a Falcon seal, so the fleet demonstrably ARMED at " + + "this height and this is a legitimate RESTART, not a misconfiguration. Starting " + + "normally; this node will attach seals immediately, which is what its peers already " + + "do.", + attachBlock, + head, + lead, + margin, + armedEvidenceHeight); + writeAdmissionReceipt(dataDirectory, head); + return; + } + + if (admissionReceiptMatches(dataDirectory)) { + chainRelativeState = ChainRelativeGateState.RESUMED_ADMITTED; + LOG.warn( + "AERE PQC: attachment height {} is not {} block(s) ahead of this node's chain head {} " + + "(lead={}), and no block at or after {} carries a Falcon seal yet, BUT this node " + + "holds an admission receipt for exactly this height and registry, i.e. this same " + + "guard already accepted this configuration while the height was safely in the " + + "future. This is a RESTART inside the activation window, not a misconfiguration. " + + "Starting normally.", + attachBlock, + margin, + head, + lead, + evidenceFloor); + return; + } + + if (operatorAcknowledgedPastAttachBlock()) { + chainRelativeState = ChainRelativeGateState.OPERATOR_ACKNOWLEDGED; + LOG.error( + "AERE PQC: OPERATOR OVERRIDE - aere.falcon.attachBlockAcknowledgePast={} matches " + + "aere.falcon.attachBlock, so the chain-relative guard is being bypassed even though " + + "the height is not {} block(s) ahead of the chain head {} (lead={}) and no on-chain " + + "activation evidence was found. This node WILL attach Falcon seals as soon as it " + + "commits. Use this ONLY to restore a validator into an activation that has genuinely " + + "already happened. REMOVE the property after the node is back.", + attachBlock, + margin, + head, + lead); + writeAdmissionReceipt(dataDirectory, head); + return; + } + + throw new ActivationConfigException( + ActivationConfigException.Kind.UNSAFE, + "AERE-PQC-CFG-UNSAFE-05", + "AERE PQC: aere.falcon.attachBlock=" + + attachBlock + + " is NOT safely ahead of this node's chain head " + + head + + " (lead=" + + lead + + " block(s); required aere.falcon.minAttachFutureMargin=" + + margin + + "). Refusing to start (fail-closed). An attachment height that this chain has already " + + "passed - or that will pass while this node is still starting up and not yet taking " + + "part in consensus - makes THIS node begin emitting Falcon-carrying Commit messages at " + + "a block the rest of the fleet did not arm at. Every peer whose Commit decoder is " + + "strict then discards the whole Commit, decisive ECDSA seal included, and the " + + "committing set silently shrinks. No evidence of a legitimate activation was found: no " + + "header in [" + + evidenceFloor + + "," + + head + + "] carries a Falcon seal, and this node holds no admission receipt for attachBlock=" + + attachBlock + + ". REMEDY: if the fleet HAS legitimately activated at this height and this node lost " + + "its data directory, restart once with " + + "-Daere.falcon.attachBlockAcknowledgePast=" + + attachBlock + + " (the value must equal aere.falcon.attachBlock exactly). Otherwise set " + + "aere.falcon.attachBlock to a height at least " + + margin + + " blocks ahead of the fleet's head, ON EVERY VALIDATOR, and restart the fleet."); + } + + /** + * Look for on-chain proof that the fleet has already armed: a header at or after the attachment + * height carrying at least one Falcon seal. Fails CLOSED - an unreadable or undecodable header is + * not evidence. + */ + private boolean fleetAlreadyArmed( + final long head, final long evidenceFloor, final LongPredicate headerCarriesFalconSeal) { + if (headerCarriesFalconSeal == null || attachBlock > head) { + return false; + } + for (long n = head; n >= evidenceFloor && n >= 0; n--) { + try { + if (headerCarriesFalconSeal.test(n)) { + armedEvidenceHeight = n; + return true; + } + } catch (final RuntimeException e) { + LOG.warn( + "AERE PQC: could not inspect header {} for Falcon seals ({}); treating on-chain " + + "activation evidence as UNAVAILABLE (fail-closed).", + n, + e.toString()); + return false; + } + } + return false; + } + + /** + * Identity of the registry this node is configured against, so an admission receipt written for + * one chain or one manifest can never admit a different one. + */ + private String registryIdentity() { + if (manifestHash != null) { + return manifestHash; + } + if (pendingLateHash != null) { + return pendingLateHash; + } + return "none"; + } + + /** Path of the admission receipt inside the node data directory, or null if unavailable. */ + private Path admissionReceiptPath(final Path dataDirectory) { + return dataDirectory == null ? null : dataDirectory.resolve(ADMISSION_RECEIPT_FILE); + } + + /** + * Record that this guard ADMITTED this attachment height. Deliberately best-effort: a data + * directory that cannot be written must not stop a validator from starting. It is logged at ERROR + * because the consequence is real - without the receipt, a crash-restart before the activation + * height would be refused and would need the acknowledgement property. + */ + private void writeAdmissionReceipt(final Path dataDirectory, final long head) { + final Path p = admissionReceiptPath(dataDirectory); + if (p == null) { + return; + } + final Properties props = new Properties(); + props.setProperty("attachBlock", Long.toString(attachBlock)); + props.setProperty("registryIdentity", registryIdentity()); + props.setProperty("admittedAtChainHead", Long.toString(head)); + props.setProperty("admittedAtEpochMillis", Long.toString(System.currentTimeMillis())); + try { + Files.createDirectories(p.getParent()); + try (OutputStream out = Files.newOutputStream(p)) { + props.store( + out, + "AERE PQC activation admission receipt. Written by the chain-relative attachment guard " + + "when it accepted this attachment height. Presence of this file lets a later " + + "restart tell a legitimate resumption from a past-height misconfiguration. It " + + "contains no key material."); + } + } catch (final Exception e) { + LOG.error( + "AERE PQC: could NOT write the activation admission receipt to {} ({}). Startup " + + "continues, but if this node restarts before block {} it will have no local proof " + + "that this activation height was already accepted, and the chain-relative guard " + + "will refuse to start it until either the fleet has visibly armed on chain or " + + "-Daere.falcon.attachBlockAcknowledgePast={} is set.", + p, + e.toString(), + attachBlock, + attachBlock); + } + } + + /** True iff a receipt exists recording an earlier admission of this exact height and registry. */ + private boolean admissionReceiptMatches(final Path dataDirectory) { + final Path p = admissionReceiptPath(dataDirectory); + if (p == null || !Files.isRegularFile(p)) { + return false; + } + try (InputStream in = Files.newInputStream(p)) { + final Properties props = new Properties(); + props.load(in); + final String a = props.getProperty("attachBlock"); + final String id = props.getProperty("registryIdentity"); + final boolean match = + a != null + && id != null + && Long.parseLong(a.trim()) == attachBlock + && id.trim().equals(registryIdentity()); + if (!match) { + LOG.warn( + "AERE PQC: an activation admission receipt exists at {} but does NOT match the current " + + "configuration (receipt attachBlock={}, registryIdentity={}; configured " + + "attachBlock={}, registryIdentity={}). It is being IGNORED, which is the " + + "fail-closed direction: a receipt for a different activation must never admit " + + "this one.", + p, + a, + id, + attachBlock, + registryIdentity()); + } + return match; + } catch (final Exception e) { + LOG.warn( + "AERE PQC: could not read the activation admission receipt at {} ({}); treating it as " + + "ABSENT (fail-closed).", + p, + e.toString()); + return false; + } + } + + /** + * The explicit operator escape hatch. The acknowledgement must NAME the exact attachment height, + * so it cannot be a blanket "true" that survives across activations and quietly disarms this + * guard forever. An acknowledgement naming a DIFFERENT height is itself an abort: it means the + * operator is acknowledging an activation this node is not configured for. + */ + private boolean operatorAcknowledgedPastAttachBlock() { + final String v = + resolve("aere.falcon.attachBlockAcknowledgePast", "AERE_FALCON_ATTACHBLOCK_ACK_PAST"); + if (v == null) { + return false; + } + final long ack; + try { + ack = Long.parseLong(v.trim()); + } catch (final NumberFormatException e) { + throw new ActivationConfigException( + ActivationConfigException.Kind.SYNTAX, + "AERE-PQC-CFG-SYNTAX-05", + "AERE PQC: aere.falcon.attachBlockAcknowledgePast is set to a MALFORMED value '" + + v + + "' (not an integer block number). Refusing to start. This property is not a " + + "boolean: it must repeat the exact aere.falcon.attachBlock value being acknowledged " + + "(" + + attachBlock + + ")."); + } + if (ack != attachBlock) { + throw new ActivationConfigException( + ActivationConfigException.Kind.UNSAFE, + "AERE-PQC-CFG-UNSAFE-06", + "AERE PQC: aere.falcon.attachBlockAcknowledgePast=" + + ack + + " does not match aere.falcon.attachBlock=" + + attachBlock + + ". Refusing to start (fail-closed): an acknowledgement that names a different " + + "height is a stale acknowledgement left over from an earlier activation, and it " + + "must never silently authorise the current one."); + } + return true; + } + + /** + * Highest legal value for {@code aere.falcon.attachInterval}. + * + *

This is NOT a taste limit, it is {@link #ACTIVATION_EVIDENCE_LOOKBACK}. A validator that + * loses its data directory and restarts past an attachment height proves the fleet is already + * armed by finding a header carrying at least one seal within the last {@value + * #ACTIVATION_EVIDENCE_LOOKBACK} blocks; with a wider interval that window can contain no + * seal-bearing header at all, the evidence search comes up empty, and the node refuses to start + * with AERE-PQC-CFG-UNSAFE-05. The failure would arrive days later, on an unrelated restart, on + * whichever node happened to lose its disk. Refusing the interval at configuration time is the + * only place the operator can still see the connection. + */ + private static final int MAX_ATTACH_INTERVAL = ACTIVATION_EVIDENCE_LOOKBACK; + + /** + * Write the Falcon certificate only on every Nth block, counted from {@code + * aere.falcon.attachBlock}. Configurable via {@code aere.falcon.attachInterval} / {@code + * AERE_FALCON_ATTACHINTERVAL}. + * + *

WHY THIS EXISTS, measured on chain 2800 on 2026-08-08. Attachment was armed on all seven + * validators and the header went from 525 to 3844 bytes, five Falcon seals on EVERY block, about + * 200 GB per node per year against 12 GB of free disk on the tightest host. The anchor producer + * already has both an interval and a seal cap, but the assembler reached when the anchor is NOT + * armed has neither, and that assembler is the one that runs before the activation height. So the + * cheap-by-design path was unreachable precisely during the window it was needed. + * + *

Unset means EVERY block, which is exactly today's behaviour. A node that writes fewer + * certificates than its neighbours is a behaviour change on a live chain and has to be asked for, + * never delivered as a default. + * + *

There is no consensus coupling here and that is deliberate: below the anchor activation + * height no rule reads element 6 to refuse anything, so two nodes with different intervals still + * agree on exactly which headers are valid. That stops being true at the activation height, where + * PqAnchorDigestRule binds the certificate to the vanity digest, and from there the anchor's own + * interval governs. + * + * @return the interval in blocks, or empty for every block + */ + public OptionalInt attachInterval() { + final String v = resolve("aere.falcon.attachInterval", "AERE_FALCON_ATTACHINTERVAL"); + if (v == null) { + return OptionalInt.empty(); + } + final int parsed; + try { + parsed = Integer.parseInt(v.trim()); + } catch (final NumberFormatException e) { + throw new ActivationConfigException( + ActivationConfigException.Kind.SYNTAX, + "AERE-PQC-CFG-SYNTAX-09", + "AERE PQC: aere.falcon.attachInterval is set to a MALFORMED value '" + + v + + "' (not an integer number of blocks). Refusing to start: an interval that silently " + + "reverted to 'every block' would leave the operator believing a disk control is in " + + "force when it is not, and the disk would fill anyway."); + } + if (parsed < 1 || parsed > MAX_ATTACH_INTERVAL) { + throw new ActivationConfigException( + ActivationConfigException.Kind.UNSAFE, + "AERE-PQC-CFG-UNSAFE-09", + "AERE PQC: aere.falcon.attachInterval " + + parsed + + " is outside the legal range 1.." + + MAX_ATTACH_INTERVAL + + ". Refusing to start. The upper bound is the " + + ACTIVATION_EVIDENCE_LOOKBACK + + "-block window in which a restarting validator looks for proof that the fleet is " + + "already armed; a wider interval can leave that window with no seal-bearing header, " + + "and the node would then refuse to start with AERE-PQC-CFG-UNSAFE-05 days later, " + + "on whichever node happened to lose its data directory."); + } + return OptionalInt.of(parsed); + } + + /** + * Largest number of seals this node writes into a certificate on the pre-activation assembly + * path. Configurable via {@code aere.falcon.attachMaxSeals} / {@code + * AERE_FALCON_ATTACHMAXSEALS}; unset means no cap, which is today's behaviour. + * + *

This is the twin of {@code aere.pq.anchor.maxSeals}, which caps the anchor producer. K is a + * FLOOR, not a ceiling: nodes attach as many seals as reach them in time, not as many as any + * threshold asks for. Measured on 2026-08-08, five seals per block arrived with seven validators + * attaching. + * + *

Unlike the anchor cap there is no minimum tied to a K schedule, because below the activation + * height no rule counts these seals. The only floor is 1: a certificate of zero seals is not a + * cheaper certificate, it is an absent one, and the caller should unset attachment instead. + * + * @return the cap, or empty for no cap + */ + public OptionalInt attachMaxSeals() { + final String v = resolve("aere.falcon.attachMaxSeals", "AERE_FALCON_ATTACHMAXSEALS"); + if (v == null) { + return OptionalInt.empty(); + } + final int parsed; + try { + parsed = Integer.parseInt(v.trim()); + } catch (final NumberFormatException e) { + throw new ActivationConfigException( + ActivationConfigException.Kind.SYNTAX, + "AERE-PQC-CFG-SYNTAX-10", + "AERE PQC: aere.falcon.attachMaxSeals is set to a MALFORMED value '" + + v + + "' (not an integer). Refusing to start rather than silently carrying no cap."); + } + if (parsed < 1) { + throw new ActivationConfigException( + ActivationConfigException.Kind.UNSAFE, + "AERE-PQC-CFG-UNSAFE-10", + "AERE PQC: aere.falcon.attachMaxSeals " + + parsed + + " is below 1. Refusing to start: a cap of zero produces an absent certificate, not " + + "a cheaper one. To stop writing certificates, unset aere.falcon.attachBlock."); + } + return OptionalInt.of(parsed); + } + + /** + * Whether the pre-activation assembler should write a certificate into THIS block's header. + * + *

Counted from {@code attachBlock} so that every node in the fleet picks the same heights + * without any agreement protocol: the two inputs are a configured constant and the block number. + * Nodes with different intervals disagree about which blocks carry a certificate, and that is + * harmless below the activation height for the reason given on {@link #attachInterval()}. + * + * @param blockNumber the height being sealed + * @return true if a certificate should be written at this height + */ + public boolean attachesCertificateAt(final long blockNumber) { + return attachmentArmed(blockNumber) && isAttachHeight(blockNumber, attachBlock, attachInterval()); + } + + /** + * The height arithmetic of {@link #attachesCertificateAt}, with no dependency on instance state. + * + *

Kept separate and package-private so it can be measured on its own. The surrounding gate + * needs a fully constructed node with a loaded registry, a signing key and an armed attachment + * height, which is a great deal of scaffolding around one modulo; a proof that has to build all + * of that in order to ask "is 12,900,100 an attachment height" ends up measuring the scaffolding. + * + * @param blockNumber the height being sealed + * @param from the configured attachment height + * @param every the configured interval, or empty for every block + * @return true if a certificate belongs at this height + */ + static boolean isAttachHeight(final long blockNumber, final long from, final OptionalInt every) { + if (blockNumber < from) { + return false; + } + if (every.isEmpty()) { + return true; + } + return (blockNumber - from) % every.getAsInt() == 0; + } + + /** + * Minimum number of blocks by which the attachment height must lead this node's chain head at + * startup. Configurable via {@code aere.falcon.minAttachFutureMargin} / + * {@code AERE_FALCON_MIN_ATTACH_FUTURE_MARGIN}; a malformed or negative value aborts startup + * rather than silently reverting to the default, so an operator can never believe a margin is in + * force when it is not. + * + * @return the required margin in blocks + */ + public long minAttachFutureMargin() { + final String v = + resolve("aere.falcon.minAttachFutureMargin", "AERE_FALCON_MIN_ATTACH_FUTURE_MARGIN"); + if (v == null) { + return DEFAULT_MIN_ATTACH_FUTURE_MARGIN; + } + final long parsed; + try { + parsed = Long.parseLong(v.trim()); + } catch (final NumberFormatException e) { + throw new ActivationConfigException( + ActivationConfigException.Kind.SYNTAX, + "AERE-PQC-CFG-SYNTAX-06", + "AERE PQC: aere.falcon.minAttachFutureMargin is set to a MALFORMED value '" + + v + + "' (not an integer number of blocks). Refusing to start: a safety margin that " + + "silently reverted to its default would leave the operator believing a margin is in " + + "force when it is not."); + } + if (parsed < 0) { + throw new ActivationConfigException( + ActivationConfigException.Kind.SYNTAX, + "AERE-PQC-CFG-SYNTAX-06", + "AERE PQC: aere.falcon.minAttachFutureMargin '" + + v + + "' is negative. Refusing to start."); + } + return parsed; + } + + /** + * Outcome of the chain-relative startup guard. + * + * @return the gate state, NOT_CHECKED until the guard has run + */ + public ChainRelativeGateState chainRelativeState() { + return chainRelativeState; + } + + // ---- AERE A8 (2026-08-01): BIND THE REGISTRY TO CONSENSUS ---- + + /** + * A8 GUARD: refuse to start when the Falcon registry this node loaded is not the one the chain + * requires at this height. + * + *

THE DEFECT, read out of the constructor above and not guessed at. The registry that answers + * "which Falcon public key is validator index i" can come from {@code aere.falcon.registry}, a + * plain local properties file with no binding to anything. Two nodes handed two different files + * disagree about who index i is, so one and the same certificate verifies on one node and fails on + * the other. That splits the network with no attacker in it. The two anchored sources do check a + * hash, but their fail-closed action is to leave the registry EMPTY and log; the process still + * STARTS, so a node can run for days as a validator that can verify nothing and report itself + * healthy while doing it. + * + *

WHAT THIS GUARD DOES. It re-reads the registry file this node actually used, computes its + * canonical hash, and compares it against {@code config.pqRegistryHash} in genesis, which is a + * height-indexed schedule. Below the first scheduled height nothing is enforced, so the roughly + * 11.8 million blocks already on chain 2800 are untouched. At and above it, a mismatch throws and + * the node does not start; the refusal names the hash expected, the hash found, the file, the + * source kind and a fingerprint per registry row. + * + *

Call this from the same place as {@link #verifyAttachHeightAgainstChainHeadOrAbort}: after a + * chain head exists and before the network and the QBFT state machine are started, so a refusal is + * a clean refusal to start rather than a mid-flight abort. + * + *

The genesis file is located from {@code aere.pq.genesis} / {@code AERE_PQ_GENESIS}, falling + * back to {@code aere.falcon.genesis} / {@code AERE_FALCON_GENESIS}. When neither is set there is + * no schedule to read, nothing is enforced, and that fact is logged at WARN rather than passed + * over: a binding everybody believes is on and is not is worse than no binding at all. + * + * @param chainHeadNumber this node's local chain head at startup + * @param chainId the chain id, which is part of the canonical pre-image + * @return the gate state + * @throws PqRegistryHash.RegistryConfigException when a binding is active and is not satisfied + */ + public PqRegistryHash.GateState verifyRegistryBindingOrAbort( + final long chainHeadNumber, final long chainId) { + + final String genesisForSchedule = + resolve("aere.pq.genesis", "AERE_PQ_GENESIS") != null + ? resolve("aere.pq.genesis", "AERE_PQ_GENESIS") + : resolve("aere.falcon.genesis", "AERE_FALCON_GENESIS"); + + final PqRegistryHash.Schedule schedule; + if (genesisForSchedule == null) { + LOG.warn( + "AERE PQC A8: no genesis file is reachable to read config.pqRegistryHash from (neither " + + "aere.pq.genesis nor aere.falcon.genesis is set), so the Falcon registry on this " + + "node is NOT bound to consensus. The registry in use is {} ({}). Two nodes holding " + + "different registry files would disagree about which public key validator index i " + + "has, and the same certificate would verify on one and fail on the other.", + registrySourcePath == null ? "(none)" : registrySourcePath, + registrySourceKind); + schedule = PqRegistryHash.emptySchedule(); + } else { + schedule = PqRegistryHash.loadScheduleFromGenesis(Paths.get(genesisForSchedule)); + } + + return verifyRegistryBindingOrAbort(chainHeadNumber, chainId, schedule); + } + + /** + * AERE A8, the form the caller should actually use: verify the registry binding against a + * schedule that came from the genesis configuration BESU ITSELF PARSED AND BOOTED WITH. + * + *

WHY THIS OVERLOAD EXISTS, and it is not tidiness. The single-argument form above reads + * {@code config.pqRegistryHash} out of a genesis FILE whose path comes from a system property. + * That is a local file named by local configuration, which is the exact shape of the defect this + * guard was written to close. A node pointed at a stale copy of genesis would enforce a stale + * binding, or none, and would say nothing. Handing the schedule down from {@code + * GenesisConfigOptions} removes the second file entirely: the value enforced is read out of the + * same genesis object that produced this node's genesis hash, so a node that disagrees about the + * schedule already disagrees about the chain and is not on it. + * + * @param chainHeadNumber this node's local chain head at startup + * @param chainId the chain id, which is part of the canonical pre-image + * @param schedule the schedule parsed from the booted genesis configuration + * @return the gate state + * @throws PqRegistryHash.RegistryConfigException when a binding is active and is not satisfied + */ + public PqRegistryHash.GateState verifyRegistryBindingOrAbort( + final long chainHeadNumber, final long chainId, final PqRegistryHash.Schedule schedule) { + + PqRegistryHash.Registry loaded = null; + if (registrySourcePath != null) { + loaded = PqRegistryHash.loadAuto(Paths.get(registrySourcePath)); + } + + // D-081: build the height-resolved set BEFORE anything is published, from the registry this + // node signs with plus every registry named in the history list. With no history configured the + // set holds exactly the one registry and every answer below is what it was before D-081. + // + // AERE D-A (2026-08-06). THIS BLOCK USED TO SIT 41 LINES LOWER, AND THAT WAS THE DEFECT. The + // startup guard below was handed the single primary registry and threw + // AERE-PQC-REG-MISMATCH-01 before this code ever ran, so a node holding exactly the right files + // - the post-rotation registry as its own, the pre-rotation one as history - refused to start + // with a correct configuration. Re-confirmed 2026-08-06 with head at 2554. Consequence: after + // the first rotation, no node could be restarted without hand intervention. + // + // MOVING IT UP ADDS NO WORK ON AN UNCONFIGURED NODE: resolve() returns null, and + // parseRegistryPaths(null) returns an empty list, so nothing is opened that was not opened + // before. On chain 2800 the schedule is empty, buildSet's loop has zero iterations, and this + // whole block costs one map lookup. + final List held = new ArrayList<>(); + held.add(loaded); + final String history = resolve(PROPERTY_REGISTRY_HISTORY, "AERE_FALCON_REGISTRY_HISTORY"); + for (final java.nio.file.Path p : PqRegistryHash.parseRegistryPaths(history)) { + held.add(PqRegistryHash.loadAuto(p)); + } + final PqRegistryHash.RegistrySet set = PqRegistryHash.buildSet(schedule, held, chainId); + + PqRegistryHash.GateState state; + try { + state = PqRegistryHash.verifyOrAbort(schedule, set, chainHeadNumber, chainId); + requireAnchorHeightIsASignedHeightOrAbort(schedule, set); + } catch (final PqRegistryHash.RegistryConfigException e) { + // AERE OPTIUNI-URGENTA (2026-08-02): THE WAY BACK, and the only one that does not need a + // build. Without it, a registry file that is wrong in one byte takes down every node it was + // pushed to, and the documented recovery was to edit a unit file or rebuild. With it, the + // recovery is a restart with one flag - and the node spends the whole time it runs saying, at + // every height, that it is running unverified. + // + // NOT bypassed here, deliberately: AERE-PQC-REG-ARM-01, the refusal to arm over a registry + // with no validator addresses. That refusal is not a configuration mismatch, it is a + // guarantee that the fleet is about to halt at the activation height, and starting anyway + // would only move the halt a few blocks later. The way out of THAT one is to disarm the + // anchor, which is a different option and is why there are three and not one. + if (!registryMismatchAllow) { + throw e; + } + registryOverrideEngaged = true; + LOG.error( + "AERE PQC EMERGENCY [AERE-PQC-REG-UNSAFE-01]: STARTING ANYWAY WITH AN UNVERIFIED FALCON " + + "REGISTRY. The A8 registry-binding guard REFUSED this node ({}), and {} is set, so " + + "the refusal has been overridden BY EXPLICIT OPERATOR REQUEST. What that means, " + + "stated plainly: from the first height at which genesis requires a registry hash, " + + "this node cannot correctly decide whether a header's Falcon certificate is valid, " + + "and it will import those headers anyway. It is NOT a validating node for the " + + "post-quantum layer while this is set. WHAT TO DO: install the registry whose hash " + + "genesis names and restart WITHOUT this option. The refusal that was overridden " + + "follows in full.\n{}", + e.code(), + PROPERTY_REGISTRY_MISMATCH_ALLOW, + e.getMessage()); + state = PqRegistryHash.GateState.OVERRIDDEN_UNSAFE; + } + if (schedule.enforced() && !set.coversWholeSchedule()) { + // NOT a refusal here, and the reason is measured, not aesthetic. This method is also the + // path a node takes when it is being started BELOW the first scheduled height, where nothing + // is enforced yet and an operator legitimately holds only one file. The per-block rule + // refuses at exactly the heights that are uncovered, which is the narrowest fail-closed + // action that still names the problem. What must never happen is silence. + LOG.error( + "AERE PQC D-081: this node holds {} registry file(s) and the chain's pqRegistryHash " + + "schedule has {} entr(ies), of which the heights {} are covered by NOTHING this " + + "node holds. Every header at or after such a height will be REFUSED, and a node " + + "syncing from genesis will stop there. Name the missing registry file(s) in {} " + + "(comma-separated). The list is never pruned: one file per rotation the chain has " + + "ever performed, kept forever, because a node acquiring history has to verify every " + + "block that was ever produced.", + set.count(), + schedule.entries().size(), + set.uncoveredEntryBlocks(), + PROPERTY_REGISTRY_HISTORY); + } + this.registryBindingState = state; + // Publish the inputs the per-block half needs. Order matters: the schedule is written LAST, and + // registryBindingSatisfiedAt() reads it FIRST and returns true when it is null, so a rule that + // runs while this is being assembled sees "inert" and never sees a half-built gate. + this.registryBindingLoaded = loaded; + this.registryBindingSet = set; + this.historicalKeys.clear(); + this.registryBindingChainId = chainId; + this.registryBindingSchedule = schedule; + return state; + } + + /** + * AERE D-B (2026-08-06), THE LOCAL HALF. The height this node ARMS at must be a height the fleet + * actually signed a registry for. + * + *

WHY THIS CANNOT BE THE SAME KIND OF GUARD AS THE ONE ABOVE, and the difference is the whole + * honest limitation of D-B. {@code aere.pq.anchorBlock} is a SYSTEM PROPERTY, set per node through + * {@code BESU_OPTS}. It is in no genesis. Nothing in the document the validators hold + * byte-identically constrains it. So the agreement of the nodes on an arming height CANNOT BE + * ENFORCED by any code that runs on one node - it can only be DETECTED locally, and that is what + * this does. Enforcement would require the height to move into genesis, which is a change to the + * chain's configuration and not to this class. That is written here as a limitation, not as a + * to-do, because a reader must not mistake this for a consensus rule. + * + *

WHAT IT COMPARES. The arming height A against the {@code block} values of + * {@code config.pqRegistryHash}. A registry row's possession proof and validator claim both sign + * the bind height; the schedule entry that puts a registry in force names the same number (that is + * {@code AERE-PQC-REG-BIND-08}, enforced above). If A is not one of those heights, the moment at + * which certificates begin to carry weight is a number no validator ever put a signature on. + * Measured 2026-08-06 on a network of seven with A moved from 40 to 70: seven of seven started, + * head reached 95, agreement 7 of 7, zero refusals. + * + *

WHY IT CANNOT FIRE ON CHAIN 2800 OR ON ANY NODE RUNNING TODAY. Three independent gates, any + * one of which returns first: the anchor is not armed ({@code aere.pq.anchorBlock} unset, so + * {@code anchorArmedFrom()} is {@link Long#MAX_VALUE}); {@code config.pqRegistryHash} is in no + * genesis this fleet runs, so the schedule is not enforced; and the held registry is v1, carrying + * no signatures over any height at all. + * + * @param schedule the schedule parsed from the booted genesis + * @param set the registries this node holds + * @throws PqRegistryHash.RegistryConfigException with {@code AERE-PQC-REG-BIND-09} + */ + private void requireAnchorHeightIsASignedHeightOrAbort( + final PqRegistryHash.Schedule schedule, final PqRegistryHash.RegistrySet set) { + final long armedFrom = anchorArmedFrom(); + if (armedFrom == Long.MAX_VALUE || !schedule.enforced()) { + return; + } + final PqRegistryHash.Registry primary = set.primary(); + if (primary == null || !primary.proofBound()) { + // Nothing here signed a height. A v1 registry on an armed node is already refused, louder and + // for a better reason, by AERE-PQC-REG-ARM-02 in the constructor; this must not take that + // message over. + return; + } + for (final PqRegistryHash.ScheduleEntry e : schedule.entries()) { + if (e.block() == armedFrom) { + return; + } + } + final StringBuilder heights = new StringBuilder(); + for (final PqRegistryHash.ScheduleEntry e : schedule.entries()) { + if (heights.length() > 0) { + heights.append(", "); + } + heights.append(e.block()); + } + throw new PqRegistryHash.RegistryConfigException( + "AERE-PQC-REG-BIND-09", + "AERE PQC D-B: REFUSING TO START - this node arms at a height no validator signed for.\n" + + " FIELD: aere.pq.anchorBlock (system property, per node, read through " + + "PqAnchorConfig) versus the 'block' values of config.pqRegistryHash in genesis (" + + schedule.source() + + ").\n" + + " READ: aere.pq.anchorBlock = " + + armedFrom + + "\n" + + " config.pqRegistryHash heights = [" + + heights + + "]\n" + + " EXPECTED: the arming height is ONE of those heights. Every row of the registry " + + "carries a Falcon possession proof and an ECDSA claim by that validator's own " + + "consensus key, and both signatures cover a single bind height. From the arming " + + "height a header's Falcon certificate carries consensus weight; if that height is not " + + "one the fleet signed, the day the chain starts enforcing post-quantum seals is a " + + "number one operator typed.\n" + + " WHY NOTHING ELSE CATCHES IT: aere.pq.anchorBlock is not in genesis. It is not in " + + "the document every node holds identically, so no node can hold another node to " + + "it. This refusal is DETECTION on this node only. Two nodes with different " + + "aere.pq.anchorBlock still do not disagree about a header until the lower of the two " + + "heights, and this guard cannot see the other node's value.\n" + + " WHAT TO DO: EITHER set aere.pq.anchorBlock to one of the heights above, on every " + + "node and in the same change - a node armed alone validates differently from " + + "the rest. OR, if the activation day genuinely moved, re-run the key ceremony for the " + + "new height, put the re-signed registry's NEW hash in config.pqRegistryHash at that " + + "height, and roll it to the whole fleet. Moving the day is 14 signatures. It is " + + "supposed to be.\n" + + " CHECK THE WHOLE FLEET BEFORE RESTARTING ANYTHING: this property is per node and " + + "is in no genesis, so two nodes can disagree about it silently. Compare it across " + + "every validator and treat a split state as a fault, not as a detail."); + } + + /** + * AERE A8, the PER-BLOCK half: does the registry this node is running on satisfy the binding the + * chain requires AT THIS HEIGHT? Never throws. + * + *

WHY A STARTUP GUARD IS NOT ENOUGH, measured as a gap and not assumed. The startup guard + * answers the question once, against the chain head that existed at startup. A node that is + * already running when a ROTATION height in the schedule passes underneath it is never asked + * again: it keeps validating with a registry the chain has moved off, and it keeps reporting + * itself healthy while doing it. That is the same failure mode A8 was opened for, arriving by a + * different door. With a schedule of one entry the two guards are equivalent; with two or more, + * only this one covers the interval after the second entry. + * + *

WHAT IT DOES ON MISMATCH. It returns false, and the header rule that calls it refuses the + * header. That is deliberate and it is fail-closed: a node whose registry no longer matches what + * the chain requires cannot verify the certificates in the headers it is being offered, so + * importing them would mean asserting a check it did not perform. It stops instead, loudly, at + * the first header past the rotation. It does NOT touch proposer selection or finality. + * + * @param blockNumber the height of the header being validated + * @return true when no binding is active at that height, or the registry satisfies it + */ + public boolean registryBindingSatisfiedAt(final long blockNumber) { + final PqRegistryHash.Schedule schedule = this.registryBindingSchedule; + if (schedule == null) { + return true; // guard never ran on this node: inert, exactly as before this change + } + final Optional required = + PqRegistryHash.requiredHashAt(schedule, blockNumber); + if (required.isEmpty()) { + return true; // below the first scheduled height: the 11.8 million existing blocks are untouched + } + final PqRegistryHash.Registry loaded = this.registryBindingLoaded; + // D-081: ask the whole scheduled history, not only the entry in force at the head. Before this + // change a node held one registry, so one rotation left NO configuration that satisfied both + // the pre-rotation interval and the head, and the chain became permanently unjoinable. + final boolean ok = + PqRegistryHash.matchesAt( + schedule, this.registryBindingSet, blockNumber, this.registryBindingChainId); + if (!ok) { + // AERE OPTIUNI-URGENTA: the per-block half of the bypass. It has to consult the ARMED flag + // and not only the ENGAGED one, because the case this rule exists for is precisely the one + // the startup guard never saw: a rotation height passing underneath a node that started + // clean. In that case nothing was overridden at startup and there is nothing engaged yet. + if (registryMismatchAllow) { + registryOverrideEngaged = true; + shoutRegistryOverrideUnsafe(blockNumber, required.get(), loaded); + return true; + } + shoutRegistryBindingMismatch(blockNumber, required.get(), loaded); + } + return ok; + } + + /** + * AERE OPTIUNI-URGENTA: is this node ACTUALLY running on a suppressed registry refusal? + * + * @return true once the bypass has suppressed a real refusal, at startup or on the block path + */ + public boolean registryOverrideEngaged() { + return registryOverrideEngaged; + } + + /** + * AERE OPTIUNI-URGENTA: has an operator armed the registry bypass on this node? + * + * @return true iff the bypass property or environment variable is set + */ + public boolean registryMismatchAllowArmed() { + return registryMismatchAllow; + } + + /** + * One complete ERROR line per height, for as long as the bypass carries weight. Bounded to one per + * height for the same reason the refusal is: at a 0.5 s block period an unbounded line would bury + * the first and most useful occurrence. Bounded per height is NOT bounded once: the mission for + * this option was that the node shouts at EVERY block, and one line per height is what "every + * block" means on a path that is asked about a height more than once. + */ + private void shoutRegistryOverrideUnsafe( + final long blockNumber, + final PqRegistryHash.ScheduleEntry required, + final PqRegistryHash.Registry loaded) { + if (registryOverrideLastShout.getAndSet(blockNumber) == blockNumber) { + return; + } + LOG.error( + "AERE PQC EMERGENCY [AERE-PQC-REG-UNSAFE-02]: height {} ACCEPTED UNVERIFIED. This chain " + + "requires registry hash {} from height {}, the registry this node holds hashes to {}, " + + "and the header was imported anyway because {} is set. This node is NOT checking the " + + "post-quantum certificates in the headers it accepts. Registry in use: {} ({}, {} " + + "entries, address-bound={}). WHAT TO DO: install the registry whose hash is {} and " + + "restart WITHOUT the bypass. THIS LINE REPEATS AT EVERY HEIGHT, BY DESIGN.", + blockNumber, + required.hash(), + required.block(), + // A8: this used to print hashV1 next to `required.hash()`, which is computed with hashFor. + // Two numbers set side by side to be compared, but computed differently: for a registry + // that carries proofs the two can NEVER match, and the message sends the operator hunting + // for the defect where it is not. Measured on a fleet of seven on 2026-08-06. + loaded == null + ? "(no registry loaded)" + : PqRegistryHash.hashFor(loaded, registryBindingChainId), + PROPERTY_REGISTRY_MISMATCH_ALLOW, + loaded == null ? "(none)" : loaded.sourcePath(), + loaded == null ? "(none)" : loaded.kind(), + loaded == null ? 0 : loaded.count(), + loaded != null && loaded.addressBound(), + required.hash()); + } + + /** + * One loud, complete line per height, at most. Bounded because this sits on the header path and a + * mismatched node is offered a header roughly twice a second; an unbounded log would bury the + * first and most useful occurrence. + */ + private void shoutRegistryBindingMismatch( + final long blockNumber, + final PqRegistryHash.ScheduleEntry required, + final PqRegistryHash.Registry loaded) { + if (registryBindingLastShout.getAndSet(blockNumber) == blockNumber) { + return; + } + LOG.error( + "AERE PQC A8 [AERE-PQC-REG-BLOCK-01]: REFUSING header at height {} (fail-closed). The " + + "chain requires registry hash {} from height {} (schedule source {}), and the " + + "registry this node is running has hash {}. Registry in use: {} ({}), {} entries, " + + "address-bound={}. WHY THIS FIRES NOW AND NOT AT STARTUP: this node started under an " + + "earlier entry of the same schedule and a ROTATION height has passed underneath it. " + + "The startup guard cannot see that; this rule can. WHAT TO DO: install the registry " + + "whose hash is {} and restart. Per-row fingerprints of the registry in use: {}", + blockNumber, + required.hash(), + required.block(), + this.registryBindingSchedule == null ? "(none)" : this.registryBindingSchedule.source(), + // A8, same reason as above: this is compared against required.hash(), which is computed + // with hashFor. + loaded == null ? "(no registry loaded)" : PqRegistryHash.hashFor(loaded, registryBindingChainId), + loaded == null ? "(none)" : loaded.sourcePath(), + loaded == null ? "(none)" : loaded.kind(), + loaded == null ? 0 : loaded.count(), + loaded != null && loaded.addressBound(), + required.hash(), + rowFingerprints(loaded)); + } + + private String rowFingerprints(final PqRegistryHash.Registry loaded) { + if (loaded == null) { + return "(none)"; + } + final StringBuilder sb = new StringBuilder(); + for (int i = 0; i < loaded.count(); i++) { + if (i > 0) { + sb.append(' '); + } + sb.append('[').append(i).append(']').append(PqRegistryHash.fingerprint(loaded, i)); + } + return sb.toString(); + } + + /** + * Outcome of the A8 registry-binding guard. + * + * @return the gate state, or null when the guard has not run + */ + public PqRegistryHash.GateState registryBindingState() { + return registryBindingState; + } + + /** + * Parse a Falcon manifest JSON node ({@code count} plus {@code 0..N-1} entries) into {@code + * outPub} / {@code outAddr} and return the keccak256 of the canonical pre-image, or {@code null} + * on any failure. + * + *

Each entry is EITHER an address-bound object {@code {"addr": "0x..", "pk": "hex"}} (the + * audit-fixed form that binds the Falcon key to a validator address) OR a bare pubkey hex string + * (legacy, pubkey-only). A manifest must be uniformly one or the other; a mixed manifest is + * rejected (fail-closed). The canonical pre-image is the concatenation, in ascending + * validator-index order, of {@code addr(20) || pk} when address-bound, or {@code pk} when legacy. + */ + private static String parseManifest( + final JsonNode cfg, final Map outPub, final Map outAddr) { + if (cfg == null || cfg.isMissingNode() || !cfg.has("count")) { + return null; + } + final int count = cfg.get("count").asInt(); + if (count <= 0) { + return null; + } + Boolean addressBoundMode = null; // decided by the first entry, then enforced uniformly + for (int i = 0; i < count; i++) { + final JsonNode e = cfg.get(Integer.toString(i)); + if (e == null) { + return null; + } + final boolean bound = e.isObject(); + if (addressBoundMode == null) { + addressBoundMode = bound; + } else if (addressBoundMode != bound) { + return null; // mixed manifest -> fail-closed + } + if (bound) { + final JsonNode addrNode = e.get("addr"); + final JsonNode pkNode = e.get("pk"); + if (addrNode == null || !addrNode.isTextual() || pkNode == null || !pkNode.isTextual()) { + return null; + } + final Address addr; + try { + addr = Address.fromHexString(addrNode.asText().trim()); + } catch (final RuntimeException ex) { + return null; + } + if (addr == null) { + return null; + } + outAddr.put(i, addr); + outPub.put(i, Bytes.fromHexStringLenient(pkNode.asText().trim()).toArray()); + } else { + if (!e.isTextual()) { + return null; + } + outPub.put(i, Bytes.fromHexStringLenient(e.asText().trim()).toArray()); + } + } + final KeccakDigest kd = new KeccakDigest(256); + for (int i = 0; i < count; i++) { + if (Boolean.TRUE.equals(addressBoundMode)) { + // Base Besu d2032017: Address extends BytesHolder (not a Tuweni Bytes), so obtain the raw + // 20 address bytes via getBytes().toArray(). Byte-identical to the intended pre-image + // (addr20 || pk). Reconciled during PQ-CONSENSUS-BUILDTEST-2026-07-18 (compiles clean). + final byte[] a = outAddr.get(i).getBytes().toArray(); + kd.update(a, 0, a.length); + } + final byte[] h = outPub.get(i); + kd.update(h, 0, h.length); + } + final byte[] digest = new byte[32]; + kd.doFinal(digest, 0); + return Bytes.wrap(digest).toUnprefixedHexString(); + } + + /** + * Load the STAGE-2 late-anchor manifest file (a bare JSON object {@code {"count": N, "0": {...}, + * ...}}) into a PENDING pubkey + address map. The registry is NOT populated here; activation + * happens only via {@link #activateLateAnchor} once the on-chain anchor contract has been + * observed. + * + * @param manifestPath path to the manifest JSON file + * @param pend out-param pending pubkey map + * @param pendAddr out-param pending validator-address map + * @return keccak256(canonical manifest) 64-hex no 0x, or null on any failure + */ + private static String loadPendingLateManifest( + final String manifestPath, + final Map pend, + final Map pendAddr) { + try { + final byte[] raw = Files.readAllBytes(Paths.get(manifestPath)); + final JsonNode root = new ObjectMapper().readTree(raw); + final Map pub = new HashMap<>(); + final Map addr = new HashMap<>(); + final String computed = parseManifest(root, pub, addr); + if (computed == null) { + return null; + } + for (final Map.Entry e : pub.entrySet()) { + pend.put( + e.getKey(), new FalconPublicKeyParameters(FalconParameters.falcon_512, e.getValue())); + } + pendAddr.putAll(addr); + return computed; + } catch (final Exception e) { + LOG.error( + "AERE PQC: failed to parse late-anchor manifest {}: {} (fail-closed).", + manifestPath, + e.toString()); + return null; + } + } + + /** + * Load the validator-to-Falcon-pubkey registry from a GENESIS-ANCHORED manifest and verify it + * against the on-chain-committed manifest hash. + * + *

Fail-CLOSED: if the manifest is absent, malformed, or its keccak256 does not equal the + * on-chain anchor (i.e. a tampered pubkey or address), the registry is left EMPTY and {@code + * null} is returned. With an empty registry every Falcon seal fails to verify, so a post-fork + * BLOCKING chain rejects the block - a manifest tamper is therefore detected and cannot be + * silently accepted. + * + * @param genesisPath path to the genesis file + * @param reg out-param registry map to populate on success + * @param regAddr out-param validator-address map to populate on success + * @return the verified manifest hash (64-hex, no 0x) on success, or {@code null} on any failure + */ + private static String loadGenesisAnchoredRegistry( + final String genesisPath, + final Map reg, + final Map regAddr) { + try { + final byte[] raw = Files.readAllBytes(Paths.get(genesisPath)); + final JsonNode root = new ObjectMapper().readTree(raw); + final JsonNode cfg = root.path("config").path("aereFalconRegistry"); + final Map pub = new HashMap<>(); + final Map addr = new HashMap<>(); + final String computed = parseManifest(cfg, pub, addr); + if (computed == null) { + LOG.error( + "AERE PQC: genesis {} has no valid config.aereFalconRegistry manifest; registry EMPTY " + + "(fail-closed).", + genesisPath); + return null; + } + + final String anchored = readAnchoredHash(root); + if (anchored == null) { + LOG.error( + "AERE PQC: genesis {} has no on-chain anchored manifest hash at alloc 0x{} slot 0; " + + "registry EMPTY (fail-closed).", + genesisPath, + ANCHOR_ADDRESS); + return null; + } + if (!computed.equalsIgnoreCase(anchored)) { + LOG.error( + "AERE PQC: GENESIS MANIFEST TAMPER DETECTED - keccak256(manifest)=0x{} != " + + "genesis-anchored hash 0x{} (alloc 0x{} slot 0). Registry REJECTED / EMPTY " + + "(fail-closed); post-fork blocks will not verify.", + computed, + anchored, + ANCHOR_ADDRESS); + return null; + } + + for (final Map.Entry e : pub.entrySet()) { + reg.put( + e.getKey(), new FalconPublicKeyParameters(FalconParameters.falcon_512, e.getValue())); + } + regAddr.putAll(addr); + LOG.info( + "AERE PQC: loaded GENESIS-ANCHORED Falcon registry ({} entries, address-bound={}) from " + + "{}; manifestHash=0x{} MATCHES on-chain anchor (alloc 0x{} slot 0, committed in the " + + "genesis stateRoot / block hash). Registry is immutable from genesis.", + reg.size(), + !regAddr.isEmpty() && regAddr.size() == reg.size(), + genesisPath, + computed, + ANCHOR_ADDRESS); + return computed; + } catch (final Exception e) { + LOG.error( + "AERE PQC: failed to load genesis-anchored Falcon registry from {}: {} " + + "(registry EMPTY, fail-closed).", + genesisPath, + e.toString()); + return null; + } + } + + /** Read the anchored manifest hash from genesis alloc[ANCHOR].storage[slot0]; null if absent. */ + private static String readAnchoredHash(final JsonNode root) { + final JsonNode alloc = root.path("alloc"); + if (alloc.isMissingNode()) { + return null; + } + final Iterator> it = alloc.fields(); + while (it.hasNext()) { + final Map.Entry en = it.next(); + if (!norm(en.getKey(), 40).equals(ANCHOR_ADDRESS)) { + continue; + } + final JsonNode storage = en.getValue().path("storage"); + if (storage.isMissingNode()) { + return null; + } + final Iterator> sit = storage.fields(); + while (sit.hasNext()) { + final Map.Entry entry = sit.next(); + if (norm(entry.getKey(), 64).equals(ANCHOR_SLOT)) { + return norm(entry.getValue().asText(), 64); + } + } + } + return null; + } + + /** Normalise a hex string: strip 0x, lower-case, left-pad with zeros to {@code width} chars. */ + private static String norm(final String hex, final int width) { + String s = hex == null ? "" : hex.trim(); + if (s.startsWith("0x") || s.startsWith("0X")) { + s = s.substring(2); + } + s = s.toLowerCase(Locale.ROOT); + final StringBuilder b = new StringBuilder(); + for (int i = s.length(); i < width; i++) { + b.append('0'); + } + b.append(s); + return b.toString(); + } + + /** + * Process-wide lazy singleton. + * + * @return the shared instance + */ + public static FalconSealSupport instance() { + if (instance == null) { + synchronized (FalconSealSupport.class) { + if (instance == null) { + instance = new FalconSealSupport(); + } + } + } + return instance; + } + + /** + * D-177 (2026-08-28): every legacy {@code aere.falcon.*} switch now has a first-class + * algorithm-neutral twin, {@code aere.pq.sig.*} (env {@code AERE_PQ_SIG_*}). The protocol + * surface an operator touches must not name a primitive: the founder's next step is the + * Falcon+SPHINCS+ hybrid, and a second algorithm must not mean a second set of sixteen + * parallel switches. Precedence, and why it is shaped this way: + * + *

    + *
  • the NEUTRAL name wins when only it is set - new fleets never need the legacy name; + *
  • the LEGACY name still works alone - the live fleet keeps running untouched; + *
  • both set to the SAME value is fine - that is what a migration window looks like; + *
  • both set to DIFFERENT values REFUSES to start. A node that silently prefers one + * spelling turns a typo into a consensus divergence, and on 2026-08-09 a silent + * property rewrite already cost this fleet a live fork for two days. + *
+ */ + static String resolve(final String sysProp, final String envVar) { + final String neutralProp = + sysProp.startsWith("aere.falcon.") + ? "aere.pq.sig." + sysProp.substring("aere.falcon.".length()) + : null; + final String neutralEnv = + envVar.startsWith("AERE_FALCON_") + ? "AERE_PQ_SIG_" + envVar.substring("AERE_FALCON_".length()) + : null; + final String neutral = firstNonBlank( + neutralProp == null ? null : System.getProperty(neutralProp), + neutralEnv == null ? null : System.getenv(neutralEnv)); + final String legacy = firstNonBlank(System.getProperty(sysProp), System.getenv(envVar)); + if (neutral != null && legacy != null && !neutral.trim().equals(legacy.trim())) { + throw new ActivationConfigException( + ActivationConfigException.Kind.SYNTAX, + "AERE-PQC-CFG-DUAL-NAME-01", + "AERE PQC: " + + neutralProp + + " and its legacy twin " + + sysProp + + " are BOTH set, to DIFFERENT values ('" + + neutral + + "' vs '" + + legacy + + "'). Refusing to start: a node that silently prefers one spelling turns a typo " + + "into a consensus divergence. Set exactly one, or both to the same value."); + } + return neutral != null ? neutral : legacy; + } + + private static String firstNonBlank(final String a, final String b) { + if (a != null && !a.isBlank()) { + return a; + } + if (b != null && !b.isBlank()) { + return b; + } + return null; + } + + // ---- STAGE-2 LATE-ANCHOR API ---- + + /** + * Whether a late-anchor manifest is configured, parsed, and still awaiting on-chain activation. + * Returns false once activated, terminally failed, or when late-anchor mode is not configured. + * + * @return true iff consensus code should keep looking for the on-chain anchor contract + */ + public boolean lateAnchorPending() { + return anchorContractAddress != null && !lateActivated && !lateFailed; + } + + /** + * The configured on-chain anchor contract address for the late-anchor registry source. + * + * @return the 0x-hex contract address, or null when late-anchor mode is not configured + */ + public String anchorAddress() { + return anchorContractAddress; + } + + /** + * Whether the registry has been activated from the late-anchor (contract-anchored) source. + * + * @return true iff late-anchor activation succeeded + */ + public boolean lateAnchored() { + return lateActivated; + } + + /** + * Whether the late anchor is TERMINALLY FAILED, i.e. an on-chain anchor was observed and its hash + * did NOT match the local manifest (tamper, or a wrong manifest shipped to this node). + * + *

AERE FIX-OPRIRE-CONSENS (c): this is the state the old code had no way to express. {@link + * #lateAnchorPending()} returns false both while a legitimate anchor has simply not landed yet AND + * after a tampered anchor was rejected, and the validation rule keyed BLOCKING off {@code + * genesisAnchored() || lateAnchored()} alone. A tampered anchor therefore silently degraded the + * whole PQC layer to LOG-ONLY: it failed OPEN. PENDING and terminally FAILED are now distinct, and + * the rule fails CLOSED on FAILED. + * + * @return true iff a manifest/anchor mismatch was detected and the registry is permanently empty + */ + public boolean lateAnchorFailed() { + return lateFailed; + } + + /** + * STAGE-2: activate the pending late-anchor registry against the manifest hash observed in the + * anchor contract's storage slot 0 in the chain's world state. + * + *

Idempotent and fail-closed: on a hash match the pending manifest becomes the live registry; + * on a mismatch the registry stays EMPTY and the late-anchor path is terminally failed (so a + * post-fork BLOCKING chain rejects blocks rather than accepting an unverified manifest). + * + * @param onChainHash the 32-byte hash read from the anchor contract's slot 0 (hex, 0x optional) + * @return true iff the registry is (now) activated from the late anchor + */ + public synchronized boolean activateLateAnchor(final String onChainHash) { + if (lateActivated) { + return true; + } + if (lateFailed || anchorContractAddress == null || pendingLateHash == null) { + return false; + } + final String observed = norm(onChainHash, 64); + if (!pendingLateHash.equalsIgnoreCase(observed)) { + LOG.error( + "AERE PQC: LATE-ANCHOR MANIFEST TAMPER DETECTED - keccak256(local manifest)=0x{} != " + + "on-chain anchor 0x{} at contract {} slot 0. Registry REJECTED / EMPTY " + + "(fail-closed); post-fork blocks will not verify.", + pendingLateHash, + observed, + anchorContractAddress); + lateFailed = true; + return false; + } + registry.putAll(pendingLate); + if (pendingLateAddresses != null) { + indexToAddress.putAll(pendingLateAddresses); + } + manifestHash = pendingLateHash; + lateActivated = true; + LOG.info( + "AERE PQC: LATE-ANCHOR ACTIVATED - Falcon registry ({} entries, address-bound={}) is now " + + "live; keccak256(manifest)=0x{} MATCHES the on-chain anchor at contract {} slot 0 " + + "(readable via eth_getStorageAt). Hybrid PQC certificate can now be verified on an " + + "already-live chain with NO re-genesis.", + registry.size(), + addressBound(), + pendingLateHash, + anchorContractAddress); + return true; + } + + /** + * Whether this node holds a Falcon signing key and should attach a parallel seal. + * + * @return true if signing is enabled + */ + public boolean signingEnabled() { + return signingEnabled; + } + + /** + * This node's validator index in the Falcon registry. + * + * @return the local validator index, or -1 if signing is disabled + */ + public int localIndex() { + return localIndex; + } + + /** + * Number of validators in the Falcon registry. + * + * @return the registry size + */ + public int registrySize() { + return registry.size(); + } + + // ---- AERE audit fix (AUD-CONSENSUS-1 / -2): address binding + eligible-signer set ---- + + /** + * Whether the active registry binds every one of its Falcon keys to a validator ADDRESS. Only an + * address-bound registry can produce a well-defined {@code currentValidators INTERSECT registry} + * eligible-signer set, so ONLY an address-bound registry may arm blocking. The legacy pubkey-only + * {@code aere.falcon.registry} file and an empty (pending / unconfigured) registry both return + * false, so a post-fork block over such a registry fails closed. + * + * @return true iff the registry is non-empty and every registry index has a bound validator + * address + */ + public boolean addressBound() { + return !registry.isEmpty() && indexToAddress.size() == registry.size(); + } + + /** + * The validator ADDRESS bound to a registry index, or {@code null} if the index is unregistered + * or the registry is not address-bound. + * + * @param validatorIndex the registry index carried by a Falcon seal + * @return the bound validator address, or null + */ + public Address addressForIndex(final int validatorIndex) { + return indexToAddress.get(validatorIndex); + } + + /** + * AERE 2026-08-07. Refuses to start when the Falcon key this node loaded belongs to a DIFFERENT + * validator, i.e. when the registry binds the key's index to an address that is not this node's. + * + *

WHY THIS EXISTS, and it is a measurement not a worry. Seven test nodes, one variable changed: + * node 0 was handed index 1's key file. It started clean and logged {@code loaded local Falcon + * signing key for validator index 1} - the very same line the node that really is index 1 logs. + * Zero refusals, zero mismatch lines. The chain then crossed the K=3 threshold height without a + * pause, to height 86, with zero rejected blocks on the healthy nodes and zero divergence, because + * the seals VERIFY: it is genuinely index 1's key. What was silently lost is that index 0 + * disappeared from the signer set and index 1 was duplicated. The N=7, K=3, f=2 margin is computed + * over SEVEN DISTINCT signers; you have six and nothing tells you. + * + *

Nothing else catches it. Not startup, not the log, not the block rhythm, not rejections, not + * the divergence detector - every node agrees, because the certificate is valid. Until today the + * only guard was a PROCEDURE step run by hand on each target, and a procedure step can be skipped. + * The runbook claimed it "shows up as R2 rejections at the anchor height"; measured, it does not. + * + *

WHY IT IS SAFE TO REFUSE HERE. Same argument as the other startup guards: this runs before + * the network and the QBFT state machine start, so it is a clean refusal to start rather than a + * mid-flight halt. It is INERT unless a Falcon key is configured AND the registry is address-bound, + * so on chain 2800 as it stands today - no {@code aere.falcon.key} anywhere - it cannot fire. + * + *

A null bound address is NOT treated as a failure: that means a pubkey-only registry, which is + * already refused elsewhere for the paths that matter, and turning it into a second refusal here + * would move the operator's attention to the wrong place. + * + * @param thisNodeAddress this node's own validator address, derived from its node key + */ + public void verifyLocalKeyBelongsToThisNodeOrAbort(final Address thisNodeAddress) { + if (localIndex < 0 || localPrivateKey == null) { + return; // no local Falcon key: nothing to bind + } + if (thisNodeAddress == null) { + LOG.warn( + "AERE PQC KEY-ADDR: this node holds Falcon key index {} but its own validator address " + + "could not be derived, so the key-to-machine binding was NOT checked. This is the " + + "one failure mode that stays invisible afterwards.", + localIndex); + return; + } + final Address bound = indexToAddress.get(localIndex); + if (bound == null) { + LOG.warn( + "AERE PQC KEY-ADDR: this node holds Falcon key index {} and the registry binds NO address " + + "to that index (pubkey-only registry), so the key-to-machine binding cannot be " + + "checked. A key placed on the wrong machine would not be detectable here.", + localIndex); + return; + } + if (bound.equals(thisNodeAddress)) { + LOG.info( + "AERE PQC KEY-ADDR: local Falcon key index {} is bound by the registry to {}, which is " + + "this node's own validator address. Key and machine agree.", + localIndex, + bound); + return; + } + throw new ActivationConfigException( + ActivationConfigException.Kind.UNSAFE, + "AERE-PQC-KEY-ADDR-01", + "AERE PQC: REFUSING TO START (fail-closed). The local Falcon key index does not map to " + + "this node.\n" + + " READ: aere.falcon.key carries index " + + localIndex + + ", and the registry binds that index to validator " + + bound + + ".\n" + + " BUT: this node's own validator address is " + + thisNodeAddress + + ".\n" + + " MEANING: this machine was handed ANOTHER validator's Falcon key. If it started, it " + + "would sign as " + + bound + + ", so that index would be produced by two machines while this node's own index would " + + "never appear in any certificate. The quorum would be silently one signer short: the " + + "N/K/f margin is computed over DISTINCT signers.\n" + + " WHY YOU WOULD NOT HAVE NOTICED: measured 2026-08-07 on seven nodes, this exact " + + "mistake produces a chain that crosses the threshold height with no pause, no rejected " + + "blocks, no divergence and no error line anywhere.\n" + + " FIX: put the key whose index the registry binds to " + + thisNodeAddress + + " on this machine, and run the on-target check " + + "(VerificaPotrivireaCheilor) before starting."); + } + + /** + * D2 HARDENING (a). THIS NODE's own signing identity, which is the one place in the stack that + * legitimately has no height. + * + *

WHY IT IS A SEPARATE METHOD AND NOT {@code addressForIndex(localIndex())}. The adversarial + * review of 2026-08-02 (D2) found the verification path asking the registry questions with no + * height. Deleting the height-less pair from {@link PqSignerRegistry} left exactly one honest + * caller behind: {@code QbftBlockCreatorAdaptor} asking "am I, right now, an eligible signer", + * before signing with the single private key this process holds. There is no historical question + * in that, and inventing a height for it would be a lie dressed as rigour. Giving it its own name + * means the two uses can no longer be confused by a future reader, which is how D2 arrived in the + * first place. + * + * @return this node's registry-bound address, or null when it holds no key or the registry is not + * address-bound + */ + public Address localSigningAddress() { + return indexToAddress.get(localIndex); + } + + /** + * D2 HARDENING (b). The height at and above which this node is ARMED, i.e. from which a header's + * Falcon certificate carries consensus weight. + * + *

WHY THE HEIGHT-RESOLVED LOOKUPS NEED IT. Before this, {@code keyAt} and {@code + * addressForIndexAt} fell back to the HEAD registry whenever no {@code config.pqRegistryHash} + * entry was in force - and {@code pqRegistryHash} is in no genesis this fleet runs (measured + * 2026-08-05: {@code grep -rn pqRegistryHash --include=*.json deploy/ monitoring/} returns + * nothing). So the whole D-081 machinery was inert and the answer above the arming height was + * still "verify this year-old header against today's keys", which IS D2/T2, unrepaired. Falling + * back is correct BELOW the arming height, where no certificate is being judged; at and above it, + * the honest answer to "which keys were in force here" is a refusal, not a guess. + * + *

Read through {@link PqAnchorProducer#config()} so the value is the same one the producer and + * the R1/R2 rules use, memoised there. Never throws: a broken anchor configuration already fails + * closed at construction, and this method must not be the thing that turns a diagnostic into an + * exception on the per-block path. + * + *

INERT ON CHAIN 2800 AS IT STANDS. {@code aere.pq.anchorBlock} is unset on the live fleet, so + * {@code everActive()} is false, this returns {@link Long#MAX_VALUE}, no height is at or above it, + * and every answer below is byte-for-byte what it was before. This code cannot stop a validator + * that is running today. + * + * @return the arming height, or {@link Long#MAX_VALUE} when the anchor is not configured + */ + private long anchorArmedFrom() { + try { + final PqAnchorConfig cfg = + org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer.config(); + return cfg.everActive() ? cfg.anchorBlock() : Long.MAX_VALUE; + } catch (final RuntimeException e) { + // An unreadable anchor configuration is already a refusal to start elsewhere. Here, the + // conservative answer is "armed from nowhere", because claiming armed would refuse headers on + // a node whose only fault is a diagnostic that threw. + return Long.MAX_VALUE; + } + } + + /** + * The set of validator addresses that have a Falcon key in the registry. This is the "signer + * registry" side of the eligible-signer intersection {@code currentValidators INTERSECT + * registry}. + * + * @return an immutable snapshot of the registered validator addresses (possibly empty) + */ + public Set

registeredValidatorAddresses() { + return Set.copyOf(indexToAddress.values()); + } + + /** + * Whether the registry covers the given (current) validator set, i.e. every current validator has + * a Falcon key in the registry. This is the ARMING INVARIANT (AUD-CONSENSUS-1): blocking should be + * armed only when coverage is complete, so the eligible-signer set equals the full validator set + * and the Falcon quorum matches the ECDSA quorum. When coverage is incomplete the blocking rule + * stays LIVE on the intersection (it never silently halts) but warns that the margin is reduced + * and a registry re-anchor is required. + * + * @param validators the current QBFT validator set + * @return true iff the registry is address-bound and contains every current validator + */ + public boolean registryCoversValidators(final Collection
validators) { + if (!addressBound()) { + return false; + } + final Set
registered = registeredValidatorAddresses(); + return registered.containsAll(validators); + } + + /** + * Whether the registry was resolved from a verified GENESIS-ANCHORED manifest (as opposed to the + * late-anchor path, the legacy mutable file, or no registry at all). + * + * @return true iff the registry is genesis-anchored and its hash matched the on-chain anchor + */ + public boolean genesisAnchored() { + return genesisAnchored; + } + + /** + * The verified keccak256 of the canonical anchored manifest (genesis-anchored or late-anchored), + * or {@code null} when no anchored registry is active. + * + * @return the manifest hash as 64-hex (no 0x prefix), or null + */ + public String registryManifestHash() { + return manifestHash; + } + + /** + * The block number at and after which the LEGACY per-block Falcon rule + * ({@link org.hyperledger.besu.consensus.qbft.headervalidationrules.FalconSealValidationRule}) + * becomes blocking. Configured via {@code aere.falcon.forkBlock} / {@code AERE_FALCON_FORKBLOCK}; + * defaults to {@link Long#MAX_VALUE} (never blocking, pure log-only) so an unconfigured node + * behaves exactly like the additive baseline. + * + *

ON CHAIN 2800 THIS ARMS A RULE THAT IS ALREADY RETIRED, so setting it changes nothing + * (finding D-235, corrected 2026-08-19). The legacy rule stands down at + * {@code PqAnchorConfig.legacyFalconRuleRetirementBlock()} = the anchor block, 13,014,000, and + * this property is set to 14,050,000 - above it. Until 2026-08-19 our own public texts said a + * per-block 2f+1 Falcon quorum had been blocking since that height; the claim was withdrawn the + * same day. What is defensible: at every 32nd height a certificate of at least K valid Falcon-512 + * seals sits under the block hash, and without it that block does not finalize. + * + *

AERE D-079 (2026-08-03): this used to re-read the property on every call and swallow a + * NumberFormatException into {@link Long#MAX_VALUE} with a WARN line. That is the finding, in one + * method: the single value the entire post-quantum enforcement layer is gated on could silently + * become "never blocking", and the only trace was a log line nothing reads. The value is now + * resolved once, in the constructor, by {@link #validateForkBlockConfigOrAbort()}, which ABORTS on + * anything it cannot parse. There is no longer any input to this method and therefore no longer + * any way for it to fail open. + * + * @return the fork block, or {@link Long#MAX_VALUE} if unconfigured + */ + public long forkBlock() { + return forkBlock; + } + + /** + * AERE D-079: the DECLARED height at or after which the on-chain late-anchor registry contract is + * expected to be observable, or {@link Long#MAX_VALUE} when undeclared. + * + * @return the declared anchor observation height + */ + public long anchorObserveBlock() { + return anchorObserveBlock; + } + + /** + * AERE D-079: the first height at which this node validated a header at or after the blocking + * height while the anchored registry was NOT active, or -1 if that has never happened. + * + *

The configuration guard refuses the misconfiguration that CAUSES this. It cannot refuse the + * accident: a correctly declared observation height whose anchor transaction never lands. From + * that height on, the node runs log-only while its operator believes the Falcon quorum is + * enforced. This counter is the difference between that situation being visible and it being a + * warning in a file. + * + * @return the first such height, or -1 + */ + public long blockingArmedWithoutRegistrySince() { + return blockingArmedWithoutRegistrySince.get(); + } + + /** + * Record that the blocking height has been reached with no active anchored registry. Never throws; + * called from the header-validation path. + * + * @param blockNumber the height at which the condition was observed + * @return true iff this is the FIRST height at which it was observed, so the caller can log once + * instead of once per block (a per-block line at a sub-second block period is a disk hazard, + * measured on this fleet in a different form on 2026-08-02) + */ + public boolean noteBlockingArmedWithoutActiveRegistry(final long blockNumber) { + final boolean first = blockingArmedWithoutRegistrySince.compareAndSet(-1L, blockNumber); + if (first) { + LOG.error( + "AERE PQC [AERE-PQC-ARM-INERT-01]: block {} is at or after the Falcon BLOCKING height {} " + + "and the anchored registry is NOT active, so the Falcon quorum is NOT being " + + "enforced and will not be until the anchor lands. Declared observation height was " + + "{}. This node is running the additive LOG-ONLY baseline while configured to " + + "enforce a post-quantum quorum. Deploy or re-observe the anchor contract {}.", + blockNumber, + forkBlock, + anchorObserveBlock == Long.MAX_VALUE ? "undeclared" : Long.toString(anchorObserveBlock), + anchorContractAddress == null ? "(none configured)" : anchorContractAddress); + } + return first; + } + + /** + * AERE audit fix (AUD-CONSENSUS-1) fail-closed ARMING diagnostic, emitted once at construction. + * If a fork block is configured (blocking is intended) but the manifest that will back it is not + * address-bound, the {@code currentValidators INTERSECT registry} eligible-signer set cannot be + * formed and post-fork blocks will fail closed. We log this LOUDLY here (arm/config time) rather + * than let it surface as a silent block-production halt later. We deliberately do NOT throw: this + * class must never throw on the consensus path, so the guarantee is loud-diagnostic-plus-fail- + * closed-reject (in the rule), not process abort. + */ + private void armingReadinessDiagnostic() { + final long fork = forkBlock(); + if (fork == Long.MAX_VALUE) { + return; // blocking not configured; nothing to arm + } + final boolean pendingAddressBound = + pendingLate != null + && pendingLateAddresses != null + && !pendingLate.isEmpty() + && pendingLateAddresses.size() == pendingLate.size(); + if (addressBound() || (anchorContractAddress != null && pendingAddressBound)) { + LOG.info( + "AERE PQC: blocking configured (forkBlock={}); backing manifest is ADDRESS-BOUND, so the " + + "eligible-signer set (currentValidators INTERSECT registry) is well defined.", + fork); + return; + } + // AERE A8 REPAIR (2026-08-02). This used to be LOG.error and nothing else, and the comment + // above still says "diagnostic only". That was measured to be the wrong trade. A node that + // starts here does not stay harmless: it joins the fleet, reaches the activation height, and + // from that height rejects every header that carries a certificate. Measured on three + // observers: head 19 with R2=4 rejections, against head 374 with R1=0 R2=0 for the + // address-bound source on the same honest chain. A whole fleet configured this way stops, and + // stops in the shape of a consensus failure rather than a configuration error, which is the + // most expensive shape a configuration error can take. + // + // So it now REFUSES TO START. The reasons this is safe to do HERE, and only here: this is the + // constructor, i.e. config time, on the same path as validateForkBlockConfigOrAbort() and + // validateAndResolveAttachBlockOrAbort(), which already abort; the network is not up; the QBFT + // state machine has not been created; and no header has been offered to anybody. The promise + // that this class never throws is a promise about the PER-BLOCK path, and it is kept: nothing + // below this constructor throws. + // + // The refusal is inert on chain 2800 as it stands today, because forkBlock is unset there, so + // this code cannot stop a live validator that is running now. + throw new ActivationConfigException( + ActivationConfigException.Kind.UNSAFE, + "AERE-PQC-REG-ARM-01", + "AERE PQC A8: REFUSING TO START (fail-closed). Falcon blocking is " + + "configured at forkBlock=" + + fork + + " but the active Falcon registry is NOT ADDRESS-BOUND" + + " (registrySize=" + + registry.size() + + ", indexToAddress=" + + indexToAddress.size() + + ", source=" + + (registrySourcePath == null ? "(none)" : registrySourcePath) + + " [" + + registrySourceKind + + "], late-anchor pending=" + + (anchorContractAddress != null) + + "). WHAT THIS MEANS: addressForIndex() returns null for every index, so the " + + "eligible-signer set (currentValidators INTERSECT registry) is empty and EVERY block " + + "at or above the activation height would be rejected by the seals rule. Measured, " + + "this is not a degradation, it is a fleet-wide halt: three observers replaying the " + + "same honest chain reached head 374 with an address-bound registry and stopped at " + + "head 19 with this one. WHAT TO DO: re-anchor the registry in an ADDRESS-BOUND form " + + "- a genesis manifest at config.aereFalconRegistry with {\"addr\",\"pk\"} entries, or " + + "a properties file carrying a '.addr=0x<20 bytes>' row for every index - and set " + + "config.pqRegistryHash to its hash, before configuring aere.falcon.forkBlock. To " + + "start this node unchanged for diagnosis, unset aere.falcon.forkBlock; the node is " + + "then log-only and cannot halt anything."); + } + + /** + * AERE D-146 (2026-08-06). REFUSE TO START when this node is ARMED and the registry it will be + * held to carries no binding proofs. + * + *

WHAT IT ADDS OVER {@code AERE-PQC-REG-ARM-01}. That guard asks whether the registry is + * ADDRESS-BOUND, i.e. whether index {@code i} has an address at all. This one asks the question + * that decides who a seal is CREDITED to: does row {@code i} carry a Falcon possession proof over + * its own {@code (chainId, bindHeight, count, i, address, publicKey)} pre-image, and an ECDSA + * claim over the same bytes signed by that validator's own consensus key. Without both, whoever + * writes the registry file decides attribution, and the threshold itself becomes fiction: two + * indices carrying the same key satisfy K=2 with a single private key. + * + *

WHY UNIQUENESS IS NOT ENOUGH, and this is measured rather than argued. Swapping the public + * keys of two rows leaves four distinct keys and four distinct addresses, so every uniqueness + * check passes and the forged header is ACCEPTED. Uniqueness repairs the THRESHOLD. Only a + * signature by the validator repairs ATTRIBUTION. + * + *

WHEN IT FIRES. Only when this node is armed: {@code aere.falcon.forkBlock} is configured, or + * the certificate anchor is active per {@link #anchorArmedFrom()}. Both are unset on chain 2800 as + * it stands, so this refusal is INERT on the live fleet exactly as AERE-PQC-REG-ARM-01 is, and it + * cannot stop a validator that is running today. That inertness is not asserted here, it is + * measured by {@code PqInertBinaryTest}, which drives a node with no {@code aere.pq.*} + * and no {@code aere.falcon.forkBlock} property at all through this constructor. + * + *

WHAT IT DOES NOT JUDGE. A node armed with NO registry file at all returns without a word. + * D-146 is about a row that credits a seal to the wrong validator, and a registry with no rows + * credits nobody; that condition belongs to AERE-PQC-REG-ARM-01 and AERE-PQC-CFG-UNSAFE-08, which + * own it and name the numbers. See the comment at the return itself for what was measured when + * this guard tried to own it too. + * + *

WHY IT IS NOT BEHIND THE EMERGENCY BYPASS. {@code aere.falcon.registry.mismatch.allow} + * overrides the A8 hash gate, which answers "is this the file genesis named" - a question about + * configuration drift, recoverable by installing the right file. This one answers "can this + * registry be trusted to say who signed", and the anchor contract is IMMUTABLE once written: a + * fleet armed over unbound rows carries D-146 for the life of the chain. The way out of THIS one + * is the same as for AERE-PQC-REG-ARM-01 - do not arm, or disarm: unset {@code + * aere.falcon.forkBlock}, or set {@code aere.pq.anchor.disable=true}, which makes {@link + * #anchorArmedFrom()} return {@link Long#MAX_VALUE} and leaves {@code PqEmergencyShoutRule} + * shouting at every height. + * + *

WHY IT MAY THROW HERE. Same path, same reasoning and same precedent as + * AERE-PQC-REG-ARM-01 and AERE-PQC-CFG-UNSAFE-04 directly around it: this is the constructor, the + * network is not up, the QBFT state machine does not exist and no header has been offered to + * anybody. The promise that this class never throws is a promise about the PER-BLOCK path, and it + * is kept. + * + *

NOT MEASURED, and written rather than hidden: when the anchor configuration itself cannot be + * read, {@link #anchorArmedFrom()} answers {@link Long#MAX_VALUE} and this guard therefore does + * not fire. That case is owned by the anchor loader, which refuses to start on its own account + * (AERE-PQC-ANCHOR-CONF-01); that the refusal always reaches the operator BEFORE this constructor + * runs has not been measured here. + * + * @throws PqRegistryHash.RegistryConfigException with code {@code AERE-PQC-REG-ARM-02} when this + * node is armed over a registry with no binding proofs + */ + private void requireRegistryBindingProofsOrAbort() { + if (forkBlock() == Long.MAX_VALUE && anchorArmedFrom() == Long.MAX_VALUE) { + return; // nothing armed on this node; inert, exactly as on chain 2800 today + } + if (registrySourcePath == null) { + // AN ARMED NODE WITH NO REGISTRY AT ALL IS NOT A D-146 DEFECT, and this return is the + // difference between a guard and a blanket. D-146 is mis-ATTRIBUTION: a row that credits a + // seal to a validator who never held the key on it. That requires ROWS. With no registry + // there are no rows, indexToAddress is empty, the eligible-signer set is empty, and nobody + // can be credited with anything - there is nothing for a forged binding to say. + // + // The "armed over nothing" condition is real and it is already owned, twice, by guards that + // say more than this one could: AERE-PQC-REG-ARM-01 immediately above (when + // aere.falcon.forkBlock is set) and AERE-PQC-CFG-UNSAFE-08 immediately below, which refuses + // a positive threshold that the anchored key count cannot guarantee and names the two + // numbers. MEASURED 2026-08-06: refusing here instead took over both of those messages - + // PqForkThresholdReachabilityTest#aPositiveThresholdWithNoAnchoredKeysMustRefuseToStart got + // AERE-PQC-REG-ARM-02 where it must get AERE-PQC-CFG-UNSAFE-08 - and additionally refused + // the K=0 warm-up regime, which is the regime the fleet is meant to arm INTO. A guard that + // breaks the intended activation procedure is not fail-closed, it is just closed. + // + // KNOWN AND NOT CLOSED HERE: aere.falcon.testnetAllowSmallFleet waives + // AERE-PQC-CFG-UNSAFE-08, so on a fleet that sets it, armed + no registry + K>0 still + // starts. That is an explicitly named isolated-network waiver and it is deliberately not + // re-litigated by this guard. + return; + } + // Deliberately re-read from the source file rather than from the parsed maps: the proofs are + // per-ROW and the in-memory index-to-key / index-to-address maps have already thrown them away. + // A guard that judged the maps would be judging what survived the loader, not what was written. + PqRegistryHash.requireBindingsOrThrow(PqRegistryHash.loadAuto(Paths.get(registrySourcePath))); + } + + /** + * AERE D-078 (2026-08-03). REFUSE TO START when the armed Falcon threshold K is above the number of + * anchored-key holders this fleet is GUARANTEED to have among a block's ECDSA committers. + * + *

WHY THIS EXISTS. The 2026-08-02 repair correctly removed the fleet-wide coverage question from + * the per-commit attachment gate, because answering it with "stop attaching" is what stopped the + * chain. In its place the javadoc of {@link #attachmentArmed} promised that the same protection was + * still there, one level up: "an arm-time decision, and it is made at arm time by + * armingReadinessDiagnostic() and by the operator". Measured on 2026-08-03: {@code + * armingReadinessDiagnostic()} tests ADDRESS-BOUNDNESS and nothing else. It does not read the fleet + * size, the keyed count, or K. The protection named there did not exist. This method is it. + * + *

WHAT IT COMPARES. K is the highest threshold the staged schedule ever reaches, because that is + * the one the chain will eventually be held to; the guarantee is {@link #worstCaseKeyedSigners} + * over the DECLARED fleet size and the anchored key count. Both numbers are known at config time, + * on every node, without a chain and without a vote. The emergency ceiling is deliberately ignored: + * it can only LOWER K later, and a guard must not be satisfied by a lever an operator may not pull. + * + *

WHY IT MAY THROW HERE. Same reasoning, same path and same precedent as AERE-PQC-CFG-UNSAFE-04 + * and the A8 refusal directly above: this is the constructor, the network is not up, the QBFT state + * machine does not exist, and no header has been offered to anyone. The promise that this class + * never throws is a promise about the PER-BLOCK path, and it is kept. + * + *

WHY IT IS NOT A RUNTIME CHECK. A per-block version of this comparison would be exactly the + * defect of D-078 rebuilt: a fleet-wide fact, false on every node at the same height after an + * ordinary vote, answered with a halting action. The runtime half stays a REPORT, in {@link + * #reportRegistryCoverage}, and decides nothing. + * + *

INERT ON CHAIN 2800 as it stands: {@code aere.pq.anchorBlock} is unset there, so there is no + * threshold and no comparison. This code cannot stop a validator that is running now. + */ + private void validateThresholdReachabilityOrAbort() { + final PqAnchorConfig anchor; + try { + anchor = PqAnchorConfig.fromSystemConfiguration(); + } catch (final RuntimeException e) { + // The anchor loader already fails closed on its own account and logs why. A guard must not be + // the thing that turns an anchor-config fault into an unexplained startup failure here. + LOG.warn( + "AERE PQC: threshold-reachability guard SKIPPED - the anchor configuration could not be " + + "read ({}). This is NOT a statement that the threshold is reachable.", + e.toString()); + return; + } + if (!anchor.everActive()) { + return; + } + int k = 0; + for (final Integer step : anchor.minSealsSchedule().values()) { + if (step != null && step > k) { + k = step; + } + } + if (k <= 0) { + return; + } + + final int keyed = anchoredKeyCount(); + final int fleet = Math.max(expectedFleetSize(), keyed); + final int guaranteed = worstCaseKeyedSigners(fleet, keyed); + final int deficit = thresholdDeficit(fleet, keyed, k); + if (deficit == 0) { + LOG.info( + "AERE PQC: Falcon threshold K={} is REACHABLE - a fleet of {} with {} anchored key " + + "holder(s) guarantees {} keyed signer(s) among a block's committers.", + k, + fleet, + keyed, + guaranteed); + return; + } + + final boolean waived = + Boolean.parseBoolean( + String.valueOf( + resolve("aere.falcon.testnetAllowSmallFleet", "AERE_FALCON_TESTNET_SMALL_FLEET"))); + if (waived) { + LOG.error( + "AERE PQC: aere.falcon.testnetAllowSmallFleet=true - arming Falcon threshold K={} on a " + + "fleet of {} with only {} anchored key holder(s), which guarantees {}. A proposer " + + "can legitimately fail to assemble a certificate. This setting MUST NOT be used on " + + "mainnet.", + k, + fleet, + keyed, + guaranteed); + return; + } + + throw new ActivationConfigException( + ActivationConfigException.Kind.UNSAFE, + "AERE-PQC-CFG-UNSAFE-08", + "AERE PQC D-078: REFUSING TO START (fail-closed). The Falcon anchor is armed at height " + + anchor.anchorBlock() + + " with a staged threshold that reaches K=" + + k + + ", but this fleet is guaranteed to produce only " + + guaranteed + + " Falcon seal(s) per block: " + + fleet + + " validator(s), of which " + + keyed + + " hold an anchored Falcon key, need ceil(2N/3)=" + + BftHelpers.calculateRequiredValidatorQuorum(fleet) + + " ECDSA committers, and the unluckiest committer set takes every UNKEYED validator " + + "first. Short by " + + deficit + + ". WHAT THIS MEANS: with every node honest and every node up, a proposer can fail to " + + "assemble a certificate, and it fails on every node at once because the validator set " + + "is consensus state - the chain stops in a state where the re-anchoring transaction " + + "that would repair it can no longer be carried by any block. That is D-078. WHAT TO " + + "DO: re-anchor the manifest so that every validator holds a key (this is what makes " + + "the standing 'grow to N>=9 before arming' order safe - the manifest has to grow WITH " + + "the set, not after it), or lower the " + + PqAnchorConfig.PROPERTY_MIN_SEALS + + " schedule to at most " + + guaranteed + + ", or declare the true fleet size in aere.falcon.validatorCount if it is wrong here. " + + "Isolated test networks may set aere.falcon.testnetAllowSmallFleet=true."); + } + + /** + * AERE D-078: how many validators hold an anchored Falcon key, counting a late anchor that has not + * landed yet the same way {@link #expectedFleetSize()} does, so the two numbers being compared are + * read at the same moment from the same manifest. + * + * @return the anchored key count + */ + private int anchoredKeyCount() { + if (!registry.isEmpty()) { + return registry.size(); + } + return pendingLate == null ? 0 : pendingLate.size(); + } + + // ---- AERE FIX-OPRIRE-CONSENS (b): SEAL-ATTACHMENT GATE ---- + + /** + * The block number at and after which this node may ATTACH a Falcon seal to its Commit messages. + * Configured via {@code aere.falcon.attachBlock} / {@code AERE_FALCON_ATTACHBLOCK}; defaults to + * {@link Long#MAX_VALUE}, i.e. NEVER attach. + * + *

This is deliberately a separate, EARLIER height than {@link #forkBlock()}. The safe ordering + * is: every validator binary upgraded (keyless) -> fleet confirmed on the new binary -> + * attachment height reached, seals start flowing and are verified LOG-ONLY -> only then the fork + * height, where the Falcon quorum becomes blocking. + * + * @return the attachment height, or {@link Long#MAX_VALUE} when attachment is not configured + */ + public long attachBlock() { + return attachBlock; + } + + /** + * Record the validator set seen by the header-validation path. This is the "does the anchored + * registry cover the WHOLE fleet" input to the attachment gate, captured from the one place that + * already has an authoritative validator set for a given height. + * + * @param blockNumber the height the set was read at + * @param validators the validator set at that height + */ + public void observeValidators(final long blockNumber, final Collection

validators) { + if (validators == null || validators.isEmpty() || blockNumber < observedValidatorsHeight) { + return; + } + this.observedValidators = new LinkedHashSet<>(validators); + this.observedValidatorsHeight = blockNumber; + } + + /** + * AERE D-078: the height at which a validator set was last observed, or {@code -1} when none ever + * was. Diagnostic, and the only way a test can tell "the gate is being fed" from "the gate happens + * to say yes anyway", which is the difference the D-078 repair turns on. + * + * @return the observation height, or -1 if no validator set has been observed + */ + public long observedValidatorsHeight() { + return observedValidators == null ? -1L : observedValidatorsHeight; + } + + /** + * Whether this node may ATTACH a Falcon seal to a Commit for the given block. Never throws. + * + *

All three conditions are fleet-wide facts, so every correct node flips at the same block: + * + *

    + *
  1. an ATTACHMENT HEIGHT is configured and reached; + *
  2. the anchored Falcon registry is ACTIVE (genesis-anchored, or a late anchor already + * observed on-chain) and address-bound; + *
  3. that registry binds THIS node's own Falcon index to a validator address. + *
+ * + *

AERE D-078 (2026-08-02): condition 3 used to be "that registry COVERS every validator in + * the last observed validator set", and that is the sentence that stopped the chain. Coverage + * is a property of the validator set, which is consensus state, so an ordinary add-validator vote + * falsifies it on every node at the same height; the old answer to that was to switch attachment + * OFF, which is fleet-wide, which starves every proposer of seals, which stops the chain in a + * state where the repairing transaction can no longer be included. Measured in {@code + * PqForkValidatorSetChangeTest}. Condition 3 is now a LOCAL fact that no membership change can + * falsify; coverage is REPORTED by {@link #reportRegistryCoverage} with the margin number it + * costs, and decides nothing. + * + *

WHICH CONDITION PROVIDES WHICH PROTECTION (corrected 2026-08-01; the earlier claim here was + * wrong and is restated so nobody relies on protection that is not there): + * + *

    + *
  • CONDITION 1 is what makes a one-by-one KEY rollout harmless. {@code attachBlock} defaults + * to {@link Long#MAX_VALUE}, so a node holding a Falcon key attaches nothing until an + * operator names an activation height. That default, and nothing else, is why step 3 of the + * activation order (distribute keys, restart one at a time) cannot emit a single seal. + *
  • CONDITION 2 stops attachment when the registry backing it is absent or not address-bound, + * so a seal is never emitted against a registry that cannot be checked. + *
  • CONDITION 3 checks that the anchored registry binds THIS node's own index, so this node + * never emits a seal no verifier can attribute. It stops one node, never the fleet, and + * nothing outside this node's own configuration can change its answer. It does NOT and + * cannot check peer BINARY VERSION, and it is emphatically NOT what makes the halting order + * impossible. Until 2026-08-02 it asked the FLEET question instead - does the registry cover + * every observed validator - and switched attachment off when the answer was no; see the + * D-078 note on the method below for why that was a chain stop rather than a safeguard. + *
  • The CHAIN-RELATIVE guard, {@link #verifyAttachHeightAgainstChainHeadOrAbort}, is what + * closes that last gap. It refuses to START a node whose activation height is not safely + * ahead of the chain head, so the height cannot be one that the fleet has already walked + * past without arming. + *
+ * + *

None of these check peer binary version. That remains procedural: see step 2 of the + * activation order and the "what this fix does NOT do" section of FIX-OPRIRE-CONSENS. + * + * @param blockNumber the block whose commit is being sealed + * @return true iff attaching a Falcon seal is safe for the whole fleet + */ + public boolean attachmentArmed(final long blockNumber) { + if (attachBlock == Long.MAX_VALUE) { + if (signingEnabled && loggedAttachmentOff.compareAndSet(false, true)) { + LOG.warn( + "AERE PQC: this node HOLDS a Falcon signing key (index {}) but seal ATTACHMENT is NOT " + + "configured (aere.falcon.attachBlock unset), so NO Falcon-carrying Commit will be " + + "emitted. This is the safe default: emitting one while any peer still runs a " + + "binary with a strict Commit decoder would make that peer DISCARD the whole " + + "Commit, ECDSA seal included. Set aere.falcon.attachBlock only after the ENTIRE " + + "validator fleet runs a binary that can parse a Falcon-carrying Commit.", + localIndex); + } + return false; + } + if (blockNumber < attachBlock) { + return false; + } + if (!(genesisAnchored || lateActivated) || !addressBound()) { + if (loggedCoverageBlocked.compareAndSet(false, true)) { + LOG.error( + "AERE PQC: attachment height {} reached at block {} but the anchored registry is NOT " + + "active/address-bound (genesisAnchored={}, lateAnchored={}, lateAnchorFailed={}). " + + "Attachment stays OFF (fail-safe for liveness).", + attachBlock, + blockNumber, + genesisAnchored, + lateActivated, + lateFailed); + } + return false; + } + // AERE D-078 (2026-08-02), CONDITION 3, REPLACED. What stands here now is a LOCAL fact: does the + // anchored registry bind MY OWN index to an address, so that every verifier can resolve my seal + // to a signer. It is a property of this node and the anchored manifest, and nothing that happens + // to the validator set can falsify it. + // + // WHAT USED TO STAND HERE AND WHY IT HAD TO GO. The old condition 3 asked a FLEET question - + // "does the anchored registry cover every validator I have observed" - and answered NO by + // switching seal attachment OFF. Both halves are wrong, and the second is the halt: + // + // * an ordinary add-validator vote admits a node that, by construction, is not in a manifest + // anchored before it existed. Coverage therefore goes false on EVERY node at the SAME + // height, because the validator set is consensus state. Every node stops attaching, so no + // proposer can gather K seals, so PqAnchorProducer refuses for every proposer in turn and + // the chain stops - and it stops in a state where the re-anchoring transaction that would + // repair it can no longer be carried by any block. That is D-078, measured in + // PqForkValidatorSetChangeTest. + // * the input it read is fed by observeValidators, whose only caller stands down at the anchor + // height. Above that height the answer was either FROZEN at a set from below it, or - on any + // node whose process started above it - never set at all, which the old code also answered + // with "stop signing". A node restart is an ordinary operation; it must not be able to + // silence this node's post-quantum seal for good. + // + // "I could not measure the fleet" and "the fleet is unsafe" are different states, and only one of + // them justifies the halting action. Coverage is now REPORTED, with the liveness margin it + // actually costs, and it decides nothing. What coverage genuinely protects - that blocking is + // not ARMED over a partial manifest - is an arm-time decision, not one taken once per commit by + // every node at once. + // + // CORRECTED 2026-08-03. This comment used to name armingReadinessDiagnostic() as the place that + // arm-time decision was made. It was not: that method tests ADDRESS-BOUNDNESS and nothing else, + // and it never reads the fleet size, the keyed count, or K. So for one day the compensating + // control for this repair was a sentence in a comment. It is now validateThresholdReachabilityOrAbort(), + // which refuses to start a node whose armed threshold is above what the fleet is guaranteed to + // produce (AERE-PQC-CFG-UNSAFE-08), and it is measured in PqForkThresholdReachabilityTest. + if (localIndex < 0 || indexToAddress.get(localIndex) == null) { + if (loggedCoverageBlocked.compareAndSet(false, true)) { + LOG.error( + "AERE PQC: attachment height {} reached at block {} but the anchored registry does not " + + "bind THIS node's Falcon index {} to a validator address ({} bound entries). A " + + "seal from an index no verifier can resolve is refused by the seals rule, so " + + "emitting one would only add bytes. Attachment stays OFF for this node alone; the " + + "rest of the fleet is unaffected. Re-anchor a manifest that contains this index.", + attachBlock, + blockNumber, + localIndex, + indexToAddress.size()); + } + return false; + } + reportRegistryCoverage(blockNumber); + if (loggedAttachmentOn.compareAndSet(false, true)) { + LOG.info( + "AERE PQC: seal ATTACHMENT ARMED at block {} (attachBlock={}, anchored address-bound " + + "registry of {} entries binds this node's index {}). This node will now attach a " + + "Falcon seal to its Commit messages. Falcon quorum becomes BLOCKING at forkBlock={}.", + blockNumber, + attachBlock, + indexToAddress.size(), + localIndex, + forkBlock() == Long.MAX_VALUE ? "unset" : Long.toString(forkBlock())); + } + return true; + } + + /** + * AERE D-078 (2026-08-02). REPORT what the anchored registry covers, and what that costs in + * liveness margin. This decides nothing: it is called after the attachment gate has already said + * yes, and its only effect is a log line. + * + *

It exists because removing coverage from the gate must not also remove the operator's ability + * to see coverage. What it prints is not "incomplete" but a NUMBER, {@link + * #worstCaseKeyedSigners}, because "the registry does not cover the set" is compatible with both a + * chain that is completely fine and a chain that is one proposer turn from stopping, and only the + * number tells them apart. + * + *

Emitted when the situation CHANGES, not once per block: the interesting event is the height + * where the validator set moved, and a line per block would bury it. + * + * @param blockNumber the height being sealed + */ + private void reportRegistryCoverage(final long blockNumber) { + final Set

validators = observedValidators; + if (validators == null) { + if (!"unobserved".equals(lastCoverageSignature)) { + lastCoverageSignature = "unobserved"; + LOG.warn( + "AERE PQC: attaching Falcon seals at block {} without having observed a validator set, " + + "so registry COVERAGE is NOT MEASURED on this node. This is not a safety " + + "statement in either direction: attachment is gated on this node's own registry " + + "binding, which is satisfied. It is normal for a process that started above the " + + "anchor height. Coverage is reported as soon as a validator set is observed.", + blockNumber); + } + return; + } + final int n = validators.size(); + final int keyed = (int) validators.stream().filter(registeredValidatorAddresses()::contains).count(); + final String signature = n + "/" + keyed; + if (signature.equals(lastCoverageSignature)) { + return; + } + lastCoverageSignature = signature; + if (keyed == n) { + LOG.info( + "AERE PQC: anchored registry COVERS the validator set at block {} ({} of {} validators " + + "keyed). Falcon and ECDSA quorums coincide.", + blockNumber, + keyed, + n); + return; + } + LOG.warn( + "AERE PQC: the anchored registry does NOT cover the validator set at block {}: {} of {} " + + "validators hold an anchored Falcon key. Seal attachment CONTINUES - switching it off " + + "here is what stops a chain (D-078) - but the post-quantum margin has moved: in the " + + "worst case only {} keyed validator(s) are among the {} ECDSA committers of a block. " + + "Compare that with the Falcon threshold K in force: if it is above that number, a " + + "proposer can legitimately fail to assemble a certificate. RE-ANCHOR the manifest for " + + "the full validator set, or lower K, before relying on the post-quantum seal.", + blockNumber, + keyed, + n, + worstCaseKeyedSigners(n, keyed), + BftHelpers.calculateRequiredValidatorQuorum(n)); + } + + /** + * AERE D-078. The number of anchored-key holders a block's ECDSA committer set is GUARANTEED to + * contain, in the worst case, when {@code validators} validators are in the set and {@code keyed} + * of them hold an anchored Falcon key. + * + *

A block needs {@code ceil(2N/3)} ECDSA committed seals. An adversarial (or merely unlucky) + * choice of committers takes every unkeyed validator first, so the guaranteed keyed count is + * {@code quorum - (N - keyed)}, floored at zero. A Falcon threshold K above this number is a + * threshold the chain is not guaranteed to be able to meet, which is exactly the shape of D-078: + * at N=7 with all 7 keyed and K=5 the margin is exactly zero, one added unkeyed validator holds it + * at zero, and a second takes it negative. + * + * @param validators the size of the current validator set + * @param keyed how many of them hold an anchored Falcon key + * @return the guaranteed number of keyed signers among a block's committers, never negative + */ + public static int worstCaseKeyedSigners(final int validators, final int keyed) { + if (validators <= 0 || keyed <= 0) { + return 0; + } + final int quorum = BftHelpers.calculateRequiredValidatorQuorum(validators); + return Math.max(0, quorum - (validators - Math.min(keyed, validators))); + } + + /** + * AERE D-078 (2026-08-03). By how much a Falcon threshold {@code k} exceeds what the fleet is + * GUARANTEED to be able to produce. Zero means reachable; any positive number is a threshold a + * proposer can legitimately fail to meet with every node honest and every node up. + * + *

Zero is REACHABLE, not comfortable: at N=7 fully keyed with K=5 the deficit is 0 and the + * margin is also 0, which is why {@link #MIN_BLOCKING_VALIDATORS} exists as a separate rule. The + * two say different things and neither implies the other. + * + * @param validators the size of the validator set + * @param keyed how many of them hold an anchored Falcon key + * @param k the Falcon threshold in force + * @return {@code k} minus the guaranteed keyed signer count, floored at zero + */ + public static int thresholdDeficit(final int validators, final int keyed, final int k) { + if (k <= 0) { + return 0; + } + return Math.max(0, k - worstCaseKeyedSigners(validators, keyed)); + } + + /** + * Falcon-sign a commit hash with this node's Falcon key, producing a parallel seal. Never throws. + * + *

AERE FIX-OPRIRE-CONSENS (b): the block number is MANDATORY and the attachment gate is + * checked here, at the single point where a seal can be produced. There is deliberately no + * height-less overload, so no call site can attach a seal without passing the gate. + * + * @param blockNumber the number of the block whose commit is being sealed + * @param commitHash the same 32-byte commit hash the ECDSA committed seal signs + * @return the Falcon seal, or empty if signing is disabled, gated off, or failed + */ + public Optional sign(final long blockNumber, final Bytes32 commitHash) { + if (!signingEnabled) { + return Optional.empty(); + } + if (!attachmentArmed(blockNumber)) { + return Optional.empty(); + } + // AERE AGILITY step 4 (2026-08-24): signing goes through the scheme layer. The same + // FalconSigner underneath, but the path is now the one the hybrid will use too; the 751 + // baseline tests prove the equivalence. + final java.util.Optional sig = + ((FalconSealScheme) SealSchemes.FALCON_512).signWithParams(localPrivateKey, commitHash.toArray()); + if (sig.isEmpty()) { + LOG.warn("AERE PQC: Falcon signing failed (ECDSA seal unaffected)"); + return Optional.empty(); + } + return Optional.of(new FalconSeal(localIndex, Bytes.wrap(sig.get()))); + } + + /** + * Property naming the first height at which this node ATTACHES a post-quantum seal to its own + * PREPARE messages. Absent = never, which is the configuration of every node today. + * + *

SEPARATE from {@code aere.falcon.attachBlock}, and the separation is mandatory: if PREPARE + * emission started together with commit emission, rolling the binary onto the fleet would become + * a flag day. This way the binary can sit on every node for months before any of them emits + * anything new. + */ + public static final String PREPARE_ATTACH_PROPERTY = "aere.pq.preparePq.attachBlock"; + + /** Environment fallback for {@link #PREPARE_ATTACH_PROPERTY}. */ + public static final String PREPARE_ATTACH_ENV = "AERE_PQ_PREPAREPQ_ATTACHBLOCK"; + + /** + * The configured PREPARE attachment height, read fresh on every call. + * + *

Absent = {@link Long#MAX_VALUE}, i.e. never. A value that is PRESENT but unreadable REFUSES + * loudly instead of disarming: the lesson paid for by the anchor loader is that a mistyped + * character must never boot the node DISARMED, because then nobody finds out. + * + * @return the height, or Long.MAX_VALUE when unset + */ + public static long prepareAttachBlock() { + final String raw = resolve(PREPARE_ATTACH_PROPERTY, PREPARE_ATTACH_ENV); + if (raw == null || raw.isBlank()) { + return Long.MAX_VALUE; + } + try { + final long v = Long.parseLong(raw.trim()); + if (v < 0) { + throw new NumberFormatException("negative"); + } + return v; + } catch (final NumberFormatException e) { + throw new ActivationConfigException( + ActivationConfigException.Kind.SYNTAX, + "AERE-PQC-PREPARE-CONF-01", + "AERE PQ PREPARE: " + + PREPARE_ATTACH_PROPERTY + + " is set to '" + + raw + + "', which is not a non-negative block height. A node must REFUSE to start rather " + + "than silently run with PREPARE attachment disarmed: a disarmed node looks exactly " + + "like a correctly configured one until the day it matters."); + } + } + + /** + * Sign this node's own PREPARE, when the PREPARE attachment gate is open at this height. + * + *

It requires THREE things, and each closes one way of being wrong: + * + *

    + *
  • {@code signingEnabled}: the node holds a key. Without one nothing is emitted, and the + * ECDSA path is not touched in any way. + *
  • {@link #attachmentArmed(long)}: the same registry-coverage conditions as commit, AND the + * fact that the fleet already emits seals on commit. A PREPARE seal on a fleet that does not + * emit on commit would be something new in a network that has not yet seen anything new. + *
  • its own height, above. + *
+ * + *

NEVER THROWS except for the strict configuration case above: a signing failure is a log line + * and an empty value, exactly as at commit, because the ECDSA path must not be disturbed. + * + * @param blockNumber the height being prepared + * @param message the domain-separated PREPARE message (see PqAnchor.prepareMessage) + * @return the seal, or empty when any gate is shut + */ + public Optional signPrepare(final long blockNumber, final Bytes32 message) { + if (!signingEnabled) { + return Optional.empty(); + } + if (!attachmentArmed(blockNumber)) { + return Optional.empty(); + } + if (blockNumber < prepareAttachBlock()) { + return Optional.empty(); + } + final java.util.Optional sig = + ((FalconSealScheme) SealSchemes.FALCON_512).signWithParams(localPrivateKey, message.toArray()); + if (sig.isEmpty()) { + LOG.warn("AERE PQ PREPARE: Falcon signing failed (ECDSA path unaffected)"); + return Optional.empty(); + } + final long n = preparesSealed.incrementAndGet(); + // LOG VOLUME IS A DECISION, not an oversight. One line per PREPARE would be two lines per + // second per node, which is exactly the kind of log that gets trained away (see D-153, where a + // permanent ERROR made an entire log worthless). The first emission is worth a line, because it + // is the moment the node starts doing something new; after that, one line every 500, so a + // testnet still has a number to count. + if (n == 1L) { + LOG.info( + "AERE PQ PREPARE: this node EMITTED its first post-quantum seal on a PREPARE, at " + + "height {} (gate {}={}). From here on its PREPAREs carry a seal.", + blockNumber, + PREPARE_ATTACH_PROPERTY, + prepareAttachBlock()); + } else if (n % 500L == 0L) { + LOG.info("AERE PQ PREPARE: {} seals emitted on PREPAREs since startup.", n); + } + return Optional.of(new FalconSeal(localIndex, Bytes.wrap(sig.get()))); + } + + /** + * How many seals this node has emitted on PREPAREs since startup. + * + *

It exists so that the COVERAGE step can be measured: without a number, arming enforcement + * would be a bet. Read from the log on a testnet, and from this value in tests. + * + * @return the count + */ + public long preparesSealed() { + return preparesSealed.get(); + } + + /** + * Property naming the first height at which this node ATTACHES a post-quantum seal to its own + * PROPOSAL messages. Absent = never, which is the configuration of every node today. + * + *

SEPARATE from both the commit and the PREPARE gates, same reason: three layers that arm on + * three independent decisions must not share a switch, or the day one is turned on becomes a flag + * day for the others. + */ + public static final String PROPOSAL_ATTACH_PROPERTY = "aere.pq.proposalPq.attachBlock"; + + /** Environment fallback for {@link #PROPOSAL_ATTACH_PROPERTY}. */ + public static final String PROPOSAL_ATTACH_ENV = "AERE_PQ_PROPOSALPQ_ATTACHBLOCK"; + + /** + * The configured PROPOSAL attachment height, read fresh on every call. + * + *

Absent = {@link Long#MAX_VALUE}, i.e. never. A value that is PRESENT but unreadable REFUSES + * loudly instead of disarming - the anchor loader's lesson, once per gate, every gate. + * + * @return the height, or Long.MAX_VALUE when unset + */ + public static long proposalAttachBlock() { + final String raw = resolve(PROPOSAL_ATTACH_PROPERTY, PROPOSAL_ATTACH_ENV); + if (raw == null || raw.isBlank()) { + return Long.MAX_VALUE; + } + try { + final long v = Long.parseLong(raw.trim()); + if (v < 0) { + throw new NumberFormatException("negative"); + } + return v; + } catch (final NumberFormatException e) { + throw new ActivationConfigException( + ActivationConfigException.Kind.SYNTAX, + "AERE-PQC-PROPOSAL-CONF-01", + "AERE PQ PROPOSAL: " + + PROPOSAL_ATTACH_PROPERTY + + " is set to '" + + raw + + "', which is not a non-negative block height. A node must REFUSE to start rather " + + "than silently run with PROPOSAL attachment disarmed: a disarmed node looks exactly " + + "like a correctly configured one until the day it matters."); + } + } + + /** + * Sign this node's own PROPOSAL, when the PROPOSAL attachment gate is open at this height. + * + *

Same three requirements as {@link #signPrepare}, for the same three reasons: a key, the + * commit-layer coverage conditions, and its own height. NEVER throws except for the strict + * configuration case: a signing failure is a log line and an empty value, because the ECDSA path + * must not be disturbed. + * + * @param blockNumber the height being proposed + * @param message the domain-separated PROPOSAL message (see PqAnchor.proposalMessage) + * @return the seal, or empty when any gate is shut + */ + public Optional signProposal(final long blockNumber, final Bytes32 message) { + if (!signingEnabled) { + return Optional.empty(); + } + if (!attachmentArmed(blockNumber)) { + return Optional.empty(); + } + if (blockNumber < proposalAttachBlock()) { + return Optional.empty(); + } + final java.util.Optional sig = + ((FalconSealScheme) SealSchemes.FALCON_512).signWithParams(localPrivateKey, message.toArray()); + if (sig.isEmpty()) { + LOG.warn("AERE PQ PROPOSAL: Falcon signing failed (ECDSA path unaffected)"); + return Optional.empty(); + } + final long n = proposalsSealed.incrementAndGet(); + // Same log-volume decision as at PREPARE: a proposal is at most one per round per proposer, so + // this is quieter by nature, but the counter still steps so a testnet has a number to count. + if (n == 1L) { + LOG.info( + "AERE PQ PROPOSAL: this node EMITTED its first post-quantum seal on a PROPOSAL, at " + + "height {} (gate {}={}). From here on its proposals carry a seal.", + blockNumber, + PROPOSAL_ATTACH_PROPERTY, + proposalAttachBlock()); + } else if (n % 500L == 0L) { + LOG.info("AERE PQ PROPOSAL: {} seals emitted on PROPOSALs since startup.", n); + } + return Optional.of(new FalconSeal(localIndex, Bytes.wrap(sig.get()))); + } + + /** + * How many seals this node has emitted on its own PROPOSALs since startup. Exists so the + * coverage step can be measured, exactly as at PREPARE. + * + * @return the count + */ + public long proposalsSealed() { + return proposalsSealed.get(); + } + + /** + * The property that opens the ROUND-CHANGE attachment gate: from this height on, this node's own + * round-changes carry a Falcon seal. Emission only - enforcement is {@code + * aere.pq.roundChangePq.forkBlock}, deliberately separate so the fleet can emit long before any + * node refuses, exactly as at PREPARE and PROPOSAL. + */ + public static final String ROUNDCHANGE_ATTACH_PROPERTY = "aere.pq.roundChangePq.attachBlock"; + + /** Environment fallback for {@link #ROUNDCHANGE_ATTACH_PROPERTY}. */ + public static final String ROUNDCHANGE_ATTACH_ENV = "AERE_PQ_ROUNDCHANGEPQ_ATTACHBLOCK"; + + /** + * The configured ROUND-CHANGE attachment height, read fresh on every call. + * + *

Absent = {@link Long#MAX_VALUE}, i.e. never. A value that is PRESENT but unreadable REFUSES + * loudly instead of disarming - the anchor loader's lesson, once per gate, every gate. + * + * @return the height, or Long.MAX_VALUE when unset + */ + public static long roundChangeAttachBlock() { + final String raw = resolve(ROUNDCHANGE_ATTACH_PROPERTY, ROUNDCHANGE_ATTACH_ENV); + if (raw == null || raw.isBlank()) { + return Long.MAX_VALUE; + } + try { + final long v = Long.parseLong(raw.trim()); + if (v < 0) { + throw new NumberFormatException("negative"); + } + return v; + } catch (final NumberFormatException e) { + throw new ActivationConfigException( + ActivationConfigException.Kind.SYNTAX, + "AERE-PQC-ROUNDCHANGE-CONF-01", + "AERE PQ ROUNDCHANGE: " + + ROUNDCHANGE_ATTACH_PROPERTY + + " is set to '" + + raw + + "', which is not a non-negative block height. A node must REFUSE to start rather " + + "than silently run with ROUND-CHANGE attachment disarmed: a disarmed node looks " + + "exactly like a correctly configured one until the day it matters."); + } + } + + /** + * Sign this node's own ROUND-CHANGE, when the ROUND-CHANGE attachment gate is open at this + * height. + * + *

Same three requirements as {@link #signPrepare} and {@link #signProposal}, for the same + * three reasons: a key, the commit-layer coverage conditions, and its own height. NEVER throws + * except for the strict configuration case: a signing failure is a log line and an empty value, + * because the ECDSA path must not be disturbed. + * + * @param blockNumber the height the round-change targets + * @param message the domain-separated ROUND-CHANGE message (see PqAnchor.roundChangeMessage) + * @return the seal, or empty when any gate is shut + */ + public Optional signRoundChange(final long blockNumber, final Bytes32 message) { + if (!signingEnabled) { + return Optional.empty(); + } + if (!attachmentArmed(blockNumber)) { + return Optional.empty(); + } + if (blockNumber < roundChangeAttachBlock()) { + return Optional.empty(); + } + final java.util.Optional sig = + ((FalconSealScheme) SealSchemes.FALCON_512).signWithParams(localPrivateKey, message.toArray()); + if (sig.isEmpty()) { + LOG.warn("AERE PQ ROUNDCHANGE: Falcon signing failed (ECDSA path unaffected)"); + return Optional.empty(); + } + final long n = roundChangesSealed.incrementAndGet(); + // Round-changes only exist when rounds FAIL, so on a healthy chain this counter barely moves; + // it still steps so a testnet that forces round-changes has a number to count. + if (n == 1L) { + LOG.info( + "AERE PQ ROUNDCHANGE: this node EMITTED its first post-quantum seal on a ROUND-CHANGE, " + + "at height {} (gate {}={}). From here on its round-changes carry a seal.", + blockNumber, + ROUNDCHANGE_ATTACH_PROPERTY, + roundChangeAttachBlock()); + } else if (n % 100L == 0L) { + LOG.info("AERE PQ ROUNDCHANGE: {} seals emitted on ROUND-CHANGEs since startup.", n); + } + return Optional.of(new FalconSeal(localIndex, Bytes.wrap(sig.get()))); + } + + /** + * How many seals this node has emitted on its own ROUND-CHANGEs since startup. Exists so the + * coverage step can be measured, exactly as at PREPARE and PROPOSAL. + * + * @return the count + */ + public long roundChangesSealed() { + return roundChangesSealed.get(); + } + + /** + * Verify a Falcon seal against the registry public key for its validator index. Never throws. + * + * @param validatorIndex the signer index + * @param commitHash the commit hash that was signed + * @param signature the Falcon signature bytes + * @return true iff a registry public key exists for the index and the signature verifies + */ + public boolean verify(final int validatorIndex, final Bytes commitHash, final Bytes signature) { + return verifyWithKey(registry.get(validatorIndex), validatorIndex, commitHash, signature); + } + + /** + * D-081. The Falcon public key registered for an index AT A HEIGHT: the one carried by the + * registry the chain's schedule makes active there. + * + *

WHY A HEIGHT IS NEEDED AT ALL. A certificate inside a block at height h was produced under + * the key set the chain required at h. After a rotation the node's own head registry is a + * different key set, so checking an old block against it fails for a reason that has nothing to do + * with the block. Cosmos ADR-016 states the same requirement for the same reason and keeps a + * height-to-key mapping; the only difference here is that ours is never pruned, because this chain + * has no unbonding period and a node syncing from genesis verifies every block ever produced. + * + *

Returns the head registry's key when no binding is active at that height, which is every + * height below the schedule's first entry and every height on a chain with no schedule at all. So + * a node on chain 2800 as it stands today gets exactly what it got before D-081. + */ + private FalconPublicKeyParameters keyAt( + final long blockNumber, final int validatorIndex, final boolean historic) { + final PqRegistryHash.Schedule schedule = this.registryBindingSchedule; + final PqRegistryHash.RegistrySet set = this.registryBindingSet; + if (schedule == null || set == null) { + return headRegistryKeyOrRefuse( + blockNumber, validatorIndex, "no schedule was ever loaded", historic); + } + final Optional required = + PqRegistryHash.requiredHashAt(schedule, blockNumber); + if (required.isEmpty()) { + // D-228. The only historical question ever asked one block below the schedule's first entry + // is about the certificate carried by the block at blockNumber+1, whose governing registry + // is the one bound EXACTLY at blockNumber+1. Answer it from that entry's VERIFIED bound + // registry, never from the unverified head file. Exactly one block: at blockNumber+2 below + // the schedule nothing changes. + final Optional nextEntry = + PqRegistryHash.requiredHashAt(schedule, blockNumber + 1); + if (nextEntry.isPresent() && nextEntry.get().block() == blockNumber + 1) { + final Optional boundNext = + PqRegistryHash.registryAt(schedule, set, blockNumber + 1); + if (boundNext.isPresent()) { + for (final PqRegistryHash.Entry e : boundNext.get().entries()) { + if (e.index() == validatorIndex) { + return new FalconPublicKeyParameters(FalconParameters.falcon_512, e.publicKey()); + } + } + return null; + } + } + return headRegistryKeyOrRefuse( + blockNumber, validatorIndex, "the schedule binds no registry at this height", historic); + } + final String cacheKey = required.get().block() + ":" + validatorIndex; + final FalconPublicKeyParameters cached = historicalKeys.get(cacheKey); + if (cached != null) { + return cached; + } + final Optional active = + PqRegistryHash.registryAt(schedule, set, blockNumber); + if (active.isEmpty()) { + // The entry active here is covered by nothing this node holds. Fail closed: no key, so no + // seal verifies, and the per-block binding rule has already refused the header for the same + // reason. Silence here would be the node deciding a question it cannot answer. + return null; + } + for (final PqRegistryHash.Entry e : active.get().entries()) { + if (e.index() == validatorIndex) { + final FalconPublicKeyParameters pub = + new FalconPublicKeyParameters(FalconParameters.falcon_512, e.publicKey()); + historicalKeys.put(cacheKey, pub); + return pub; + } + } + return null; + } + + /** + * D-081. The validator ADDRESS bound to a registry index at a height. + * + *

D2 HARDENING (b-v2): PRIVATE, with the caller's motive as an argument. The two public + * doors are {@link #addressForIndexAtHistoric} and {@link #addressForIndexAtOwnHead}, each of + * which passes a constant. Nothing outside this file can choose the value, so the one shared + * resolution body cannot drift between the two paths and no caller can pick the wrong flag. + * + * @param blockNumber the height of the header being validated + * @param validatorIndex the registry index carried by a Falcon seal + * @param historic true when the subject is a header this node received, false when it is this + * node's own head or the block it is building + * @return the bound address, or null when the index is unregistered at that height or the + * registry active there is not address-bound + */ + private Address addressAt( + final long blockNumber, final int validatorIndex, final boolean historic) { + final PqRegistryHash.Schedule schedule = this.registryBindingSchedule; + final PqRegistryHash.RegistrySet set = this.registryBindingSet; + if (schedule == null || set == null) { + return headRegistryAddressOrRefuse( + blockNumber, validatorIndex, "no schedule was ever loaded", historic); + } + if (PqRegistryHash.requiredHashAt(schedule, blockNumber).isEmpty()) { + // D-228. Same alignment as in keyAt: one block below the schedule's first entry the subject + // is the certificate of the block at blockNumber+1, governed by the registry bound EXACTLY + // there, so the answer comes from that entry's VERIFIED bound registry and mirrors what + // this method answers at blockNumber+1 itself. + final Optional nextEntry = + PqRegistryHash.requiredHashAt(schedule, blockNumber + 1); + if (nextEntry.isPresent() && nextEntry.get().block() == blockNumber + 1) { + final Optional boundNext = + PqRegistryHash.registryAt(schedule, set, blockNumber + 1); + if (boundNext.isPresent()) { + if (!boundNext.get().addressBound()) { + return null; + } + for (final PqRegistryHash.Entry e : boundNext.get().entries()) { + if (e.index() == validatorIndex && e.address() != null) { + return Address.wrap(Bytes.wrap(e.address())); + } + } + return null; + } + } + return headRegistryAddressOrRefuse( + blockNumber, validatorIndex, "the schedule binds no registry at this height", historic); + } + final Optional active = + PqRegistryHash.registryAt(schedule, set, blockNumber); + if (active.isEmpty() || !active.get().addressBound()) { + return null; + } + for (final PqRegistryHash.Entry e : active.get().entries()) { + if (e.index() == validatorIndex && e.address() != null) { + return Address.wrap(Bytes.wrap(e.address())); + } + } + return null; + } + + /** + * D-081 / D2 HARDENING (b-v2). THE HISTORY DOOR. Verify a Falcon seal carried by a header this + * node RECEIVED, against the key set the chain required at that height. + * + *

WHY THE MOTIVE IS IN THE NAME AND NOT IN THE HEIGHT, measured on 2026-08-06. The first shape + * of hardening (b) refused whenever no height binding existed at or above the arming height, and + * decided that from the block number alone. That stopped the two paths that work on THIS NODE'S + * OWN head: restoring seals from disk after a restart, and proposing. Six tests went red, five in + * {@code PqSealPersistenceTest} and one in {@code PqForkValidatorSetChangeTest}, and the D078 + * message says the consequence outright - the node refuses to propose, so it stops producing + * blocks. In every one of the six the number presented to the guard was 1030 against an arming + * height of 1000, identical to what a real historical question would present in the same process + * in the same second. So the distinction moved into the question. + * + *

Reaching THIS method means the node is judging somebody else's claim about a height it did + * not build. "Which keys were in force here" then has an answer that is not this node's current + * registry, and answering from the head registry anyway is D2/T2 verbatim: a header produced under + * one key set checked against another, with success reported. + * + * @param blockNumber the height of the header carrying the seal + * @param validatorIndex the signer index + * @param commitHash the message that was signed + * @param signature the Falcon signature bytes + * @return true iff a key was bound for that index AT THAT HEIGHT and the signature verifies + */ + public boolean verifyAtHistoric( + final long blockNumber, + final int validatorIndex, + final Bytes commitHash, + final Bytes signature) { + return verifyWithKey( + keyAt(blockNumber, validatorIndex, true), validatorIndex, commitHash, signature); + } + + /** + * D2 HARDENING (b-v2). THE OWN-HEAD DOOR. Verify a Falcon seal over a block this node holds as its + * own head, or is building right now. + * + *

It does not refuse for a missing height binding, because at this node's own head the head + * registry IS the answer by construction. A wrong local acceptance cannot manufacture history: the + * certificate is re-checked by the other six validators through {@code PqAnchorSealsRule}, which + * goes through {@link #verifyAtHistoric} at the parent's height. The cost of a local mistake is + * one lost proposer round, not a branch. + * + *

What it still refuses: a height the schedule DOES cover with a registry this node does not + * hold. That is not a missing binding, it is a node running a registry the chain has moved off, + * and it fails closed on both doors - see the {@code registryAt} branch of {@link #keyAt}. + * + * @param blockNumber this node's own head, or the block it is sealing + * @param validatorIndex the signer index + * @param commitHash the message that was signed + * @param signature the Falcon signature bytes + * @return true iff a key exists for the index there and the signature verifies + */ + public boolean verifyAtOwnHead( + final long blockNumber, + final int validatorIndex, + final Bytes commitHash, + final Bytes signature) { + return verifyWithKey( + keyAt(blockNumber, validatorIndex, false), validatorIndex, commitHash, signature); + } + + /** + * D-081 / D2 HARDENING (b-v2). THE HISTORY DOOR, address half. See {@link #verifyAtHistoric}. + * + * @param blockNumber the height of the header being validated + * @param validatorIndex the registry index carried by a Falcon seal + * @return the bound address, or null when nothing binds that index at that height + */ + public Address addressForIndexAtHistoric(final long blockNumber, final int validatorIndex) { + return addressAt(blockNumber, validatorIndex, true); + } + + /** + * D2 HARDENING (b-v2). THE OWN-HEAD DOOR, address half. See {@link #verifyAtOwnHead}. + * + * @param blockNumber this node's own head, or the block it is building + * @param validatorIndex the registry index + * @return the bound address, or null + */ + public Address addressForIndexAtOwnHead(final long blockNumber, final int validatorIndex) { + return addressAt(blockNumber, validatorIndex, false); + } + + /** + * D2 HARDENING (b). The one decision the whole hardening turns on, isolated so that removing it is + * a one-line edit and the negative control can prove the D2 test goes red without it. + * + *

THE MEASURED DEFECT. Both height-resolved lookups used to answer a height they had no binding + * for by returning the HEAD registry - the key set in force right now. Below the arming height + * that is correct and costs nothing: no certificate is being judged there. At and above it, it is + * D2/T2 verbatim: "an armed node verifies a year-old header against the keys it holds today", so + * one rotation makes every block between the arming height and the rotation unverifiable, and the + * node reports success while doing it. Refusing is the only answer that does not assert a check + * that was not performed. + * + *

WHAT A REFUSAL COSTS AND WHY IT IS THE CHEAPER FAILURE. A null key makes every seal fail, so + * {@code PqAnchorSealsRule} refuses the header and the node stops at the first block at or above + * the arming height. It stops; it does not fork. A node that cannot say which keys were in force + * cannot forge a competing history either, and the repair is to configure {@code + * config.pqRegistryHash} plus {@code aere.falcon.registry.history} and restart. Compare the + * silence this replaces, where the same node imports the whole chain and calls it verified. + * + *

D2 HARDENING (b-v2), 2026-08-06. The condition gained ONE term, {@code historic}, and + * that term is the whole of the second repair. The first shape refused on height alone; the + * six tests it turned red were all asking about this node's OWN head at height 1030 with an + * arming height of 1000, which is the same pair of numbers a genuinely historical question + * presents. The block number cannot tell them apart. The caller can, and now says so. + * + * @param blockNumber the height being asked about + * @param validatorIndex the registry index + * @param why the reason no height binding was found, for the log + * @param historic true when the subject is a received header, false for this node's own head + * @return the head registry's key when not historic or below the arming height, else null + */ + private FalconPublicKeyParameters headRegistryKeyOrRefuse( + final long blockNumber, + final int validatorIndex, + final String why, + final boolean historic) { + if (!historic || blockNumber < anchorArmedFrom()) { + return registry.get(validatorIndex); + } + shoutUnboundHeight(blockNumber, why); + return null; + } + + /** + * D2 HARDENING (b). The address half of {@link #headRegistryKeyOrRefuse}, with the same rule and + * for the same reason: at and above the arming height an unbound height has no answer, and {@code + * PqAnchorSealsRule} refuses an index it cannot bind to an address rather than skipping it. + * + *

D2 HARDENING (b-v2): same one added term as {@link #headRegistryKeyOrRefuse}. + * + * @param blockNumber the height being asked about + * @param validatorIndex the registry index + * @param why the reason no height binding was found, for the log + * @param historic true when the subject is a received header, false for this node's own head + * @return the head registry's bound address when not historic or below the arming height, else + * null + */ + private Address headRegistryAddressOrRefuse( + final long blockNumber, + final int validatorIndex, + final String why, + final boolean historic) { + if (!historic || blockNumber < anchorArmedFrom()) { + return addressForIndex(validatorIndex); + } + shoutUnboundHeight(blockNumber, why); + return null; + } + + /** + * D2 HARDENING (b). Say it once per configuration change, not once per block: at a 523 ms block + * period a per-block ERROR is itself a hazard on this fleet, and the refusal is already visible as + * a stopped node. + * + * @param blockNumber the height that has no registry binding + * @param why the reason + */ + private void shoutUnboundHeight(final long blockNumber, final String why) { + if (loggedUnboundArmedHeight.compareAndSet(false, true)) { + LOG.error( + "AERE PQC D2: REFUSING to resolve Falcon keys at height {} - this node is ARMED from {} " + + "and {}. Until 2026-08-06 this fell back to the registry in force at the HEAD, " + + "which is the D2/T2 defect: a header produced under one key set was checked against " + + "another, and one key rotation would have made every block above the arming height " + + "unverifiable while the node reported success. Every header at or above the arming " + + "height will now be REFUSED until the height-to-registry binding exists. WHAT TO DO: " + + "put config.pqRegistryHash in the genesis this node booted (first entry exactly at " + + "the arming height) and name every registry file the schedule covers in {}.", + blockNumber, + anchorArmedFrom(), + why, + PROPERTY_REGISTRY_HISTORY); + } + } + + private boolean verifyWithKey( + final FalconPublicKeyParameters pub, + final int validatorIndex, + final Bytes commitHash, + final Bytes signature) { + if (pub == null || commitHash == null || signature == null) { + return false; + } + // AERE AGILITY step 4: verification goes through the scheme layer, on the registry form + // (raw h, 896 bytes). verifyRaw never throws; a false here is an invalid seal, + // exactly the old contract. + final boolean valid = + SealSchemes.FALCON_512.verifyRaw(pub.getH(), commitHash.toArray(), signature.toArray()); + if (!valid) { + LOG.debug("AERE PQC: Falcon seal did not verify for index {}", validatorIndex); + } + return valid; + } +} diff --git a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/HybridSealProducer.java b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/HybridSealProducer.java new file mode 100755 index 000000000..591ede15e --- /dev/null +++ b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/HybridSealProducer.java @@ -0,0 +1,140 @@ +/* + * AERE HYBRID, the PRODUCER half (2026-08-25). The counterpart of PqCommitEnforcement: that one + * decides what is accepted, this one decides what is EMITTED. + * + * WHY A SEPARATE CLASS FROM FalconSealSupport. Falcon has an old production path, with + * per-component loading, startup guards and a singleton; widening it would have meant touching + * the very class the live consensus hangs on, for a capability armed nowhere today. + * Falcon is not touched here at all: this class produces ONLY the seals of the other schemes, + * i.e. exactly the content of the extras slot in CommitPayload. + * + * THE EMISSION GATE IS WHY THIS CLASS IS ALLOWED TO EXIST. Adding extras changes the signed + * bytes, so an older node can no longer PARSE the message. What protects the fleet is not + * leniency at decode time, which cannot work, but the fact that nothing emits extras until the + * attach height, the same discipline as the Falcon gate. Unset means: never emit, + * EVER, and that is the default. + * + * HALF A CERTIFICATE IS NOT EMITTED. If the schedule requires a scheme this node has no key + * for, no maimed certificate is sent (every neighbour would refuse it at quorum anyway): + * nothing is sent, and the log SHOUTS. An operator must find out a key is missing + * BEFORE the height where enforcement bites, not on that very day. + */ +package org.hyperledger.besu.consensus.common.bft; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import org.apache.tuweni.bytes.Bytes; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Produces the non-Falcon scheme seals a hybrid commit carries, gated on height. */ +public final class HybridSealProducer { + + private static final Logger LOG = LoggerFactory.getLogger(HybridSealProducer.class); + + /** The disarmed attachment height: no block ever reaches it, so nothing is ever emitted. */ + public static final long NEVER = Long.MAX_VALUE; + + private final long attachFromBlock; + private final PqSchemeSchedule schedule; + private final int validatorIndex; + private final Map localKeys; + // one shout per missing scheme, not one per block: a permanent alarm is learned and ignored + private final Set alreadyShouted = new java.util.HashSet<>(); + + /** + * @param attachFromBlock first height at which extras may be emitted; {@link #NEVER} to disarm + * @param schedule which schemes are required at which height; null disarms as well + * @param validatorIndex this node's index, written into every seal it produces + * @param localKeys the private handles this node holds, per scheme id + */ + public HybridSealProducer( + final long attachFromBlock, + final PqSchemeSchedule schedule, + final int validatorIndex, + final Map localKeys) { + this.attachFromBlock = schedule == null ? NEVER : attachFromBlock; + this.schedule = schedule; + this.validatorIndex = validatorIndex; + this.localKeys = localKeys == null ? Map.of() : Map.copyOf(localKeys); + } + + /** A producer that never emits anything: the configuration of every node today. */ + public static HybridSealProducer disarmed() { + return new HybridSealProducer(NEVER, null, -1, Map.of()); + } + + /** + * Whether extras may be emitted at this height at all. + * + * @param blockNumber the height + * @return true when the attachment gate is open + */ + public boolean attachmentArmedAt(final long blockNumber) { + return schedule != null && blockNumber >= attachFromBlock; + } + + /** + * The extra scheme seals for this block, or an empty list. + * + *

Never throws: a producer fault must never take down the ECDSA commit path. Every refusal + * is a logged reason plus an empty list, exactly the stance of the Falcon signer. + * + * @param blockNumber the height of the block being committed + * @param message the very bytes the Falcon seal of this commit signs + * @return the seals, or empty when the gate is shut, a key is missing, or signing failed + */ + public List sealsFor(final long blockNumber, final Bytes message) { + if (!attachmentArmedAt(blockNumber) || message == null) { + return List.of(); + } + try { + final Set required = schedule.schemesAt(blockNumber); + final List produced = new ArrayList<>(); + for (final String schemeId : required) { + if (SealSchemes.FALCON_512.id().equals(schemeId)) { + continue; // Falcon has its own slot and its own signer; never duplicated here + } + final Optional scheme = SealSchemes.byId(schemeId); + if (scheme.isEmpty()) { + shoutOnce(schemeId, "the schedule names scheme '" + schemeId + + "' which this binary does not implement"); + return List.of(); + } + final SealScheme.PrivateHandle key = localKeys.get(schemeId); + if (key == null) { + shoutOnce(schemeId, "this node holds NO " + schemeId + + " signing key, so it cannot produce the certificate the schedule requires from" + + " height " + blockNumber + " onwards"); + return List.of(); + } + final Optional signature = scheme.get().sign(key, message.toArray()); + if (signature.isEmpty()) { + shoutOnce(schemeId, "signing with the local " + schemeId + " key FAILED"); + return List.of(); + } + produced.add( + new SchemeSeal(scheme.get().wireId(), validatorIndex, Bytes.wrap(signature.get()))); + } + // Canonical order, so two honest nodes signing the same block emit identical bytes and the + // certificate cannot become a source of gratuitous divergence. + produced.sort(PqAnchorV2.CANONICAL); + return List.copyOf(produced); + } catch (final RuntimeException e) { + LOG.warn("AERE HIBRID: producer fault at block {}, emitting nothing: {}", + blockNumber, e.getMessage()); + return List.of(); + } + } + + private void shoutOnce(final String schemeId, final String what) { + if (alreadyShouted.add(schemeId)) { + LOG.error("AERE HIBRID: {} - NO hybrid certificate will be emitted by this node." + + " Fix this BEFORE the enforcement height, not on the day.", what); + } + } +} diff --git a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/HybridSealSupport.java b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/HybridSealSupport.java new file mode 100755 index 000000000..34f362d8d --- /dev/null +++ b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/HybridSealSupport.java @@ -0,0 +1,295 @@ +/* + * AERE HYBRID, the PRODUCTION loader (2026-08-25). The only place that reads a node's hybrid + * configuration and turns it into the two already-proven pieces: HybridSealProducer + * (emission) and the schedule+registry pair for PqCommitEnforcement (enforcement). + * + * THE PROPERTIES (all via BESU_OPTS, like every AERE switch; all absent = today's node, + * byte for byte): + * aere.pq.schemeSchedule / AERE_PQ_SCHEME_SCHEDULE the schedule "H:scheme+scheme,..." + * aere.pq.hybridRegistry / AERE_PQ_HYBRID_REGISTRY path of the hybrid-1 registry + * aere.pq.hybrid.attachBlock / AERE_PQ_HYBRID_ATTACHBLOCK height from which extras are EMITTED + * aere.pq.hybrid.key. / (no env; one path per scheme) the local private key {index, sk} + * + * EACH CONFIGURATION HALF REFUSES AT STARTUP, with a code and a name: + * CONF-03 schedule without registry or the reverse (inherited from enforcement; caught earlier here) + * CONF-04 attach armed without schedule+registry: you would emit what nobody can verify + * CONF-05 the local key does not bind: index outside the registry, scheme unknown to the + * schedule, index different from the local Falcon index, or the probe signature does + * not verify against the public key the registry holds (the loader's positive + * control: a key that fails its own probe must not boot a node that believes itself armed) + * + * A mistyped comma does NOT silently boot the node disarmed: the anchor loader's lesson. + */ +package org.hyperledger.besu.consensus.common.bft; + +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.Properties; + +import org.apache.tuweni.bytes.Bytes; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Loads a node's hybrid-seal configuration once and hands out the proven parts. */ +public final class HybridSealSupport { + + private static final Logger LOG = LoggerFactory.getLogger(HybridSealSupport.class); + + /** System property naming the scheme schedule. */ + public static final String PROPERTY_SCHEDULE = "aere.pq.schemeSchedule"; + /** Environment fallback for {@link #PROPERTY_SCHEDULE}. */ + public static final String ENV_SCHEDULE = "AERE_PQ_SCHEME_SCHEDULE"; + /** System property naming the hybrid registry file path. */ + public static final String PROPERTY_REGISTRY = "aere.pq.hybridRegistry"; + /** Environment fallback for {@link #PROPERTY_REGISTRY}. */ + public static final String ENV_REGISTRY = "AERE_PQ_HYBRID_REGISTRY"; + /** System property naming the emission gate height. */ + public static final String PROPERTY_ATTACH_BLOCK = "aere.pq.hybrid.attachBlock"; + /** Environment fallback for {@link #PROPERTY_ATTACH_BLOCK}. */ + public static final String ENV_ATTACH_BLOCK = "AERE_PQ_HYBRID_ATTACHBLOCK"; + /** Prefix of the per-scheme local private key path property. */ + public static final String PROPERTY_KEY_PREFIX = "aere.pq.hybrid.key."; + + /** How this class reaches names and files; swappable so the loader itself is provable. */ + public interface ConfigReader { + /** Returns the raw system property. + * + * @param name the system property name + * @return the value, or null when absent */ + String property(String name); + /** Returns the raw environment variable. + * + * @param name the environment variable name + * @return the value, or null when absent */ + String environment(String name); + /** Returns the file's bytes. + * + * @param path the file path + * @return the bytes + * @throws IOException when unreadable */ + byte[] file(String path) throws IOException; + } + + private static final ConfigReader REAL = + new ConfigReader() { + @Override + public String property(final String name) { + return System.getProperty(name); + } + + @Override + public String environment(final String name) { + return System.getenv(name); + } + + @Override + public byte[] file(final String path) throws IOException { + try (InputStream in = new FileInputStream(path)) { + return in.readAllBytes(); + } + } + }; + + private static volatile HybridSealSupport instance; + + private final PqSchemeSchedule schedule; // null = not configured + private final HybridSignerRegistry registry; // paired with the schedule, never alone + private final HybridSealProducer producer; // never null; disarmed when there is nothing + + private HybridSealSupport( + final PqSchemeSchedule schedule, + final HybridSignerRegistry registry, + final HybridSealProducer producer) { + this.schedule = schedule; + this.registry = registry; + this.producer = producer; + } + + /** The process-wide instance, loaded from real configuration on first use. */ + public static HybridSealSupport instance() { + HybridSealSupport s = instance; + if (s == null) { + synchronized (HybridSealSupport.class) { + s = instance; + if (s == null) { + s = load(REAL); + instance = s; + } + } + } + return s; + } + + /** Drops the cached instance, for tests only. */ + public static void resetForTesting() { + instance = null; + } + + /** + * Load from a reader. Public so the loader's refusals are provable without global state. + * + * @param reader the configuration source + * @return the loaded support; fully disarmed when nothing is configured + */ + public static HybridSealSupport load(final ConfigReader reader) { + final String rawSchedule = firstOf(reader, PROPERTY_SCHEDULE, ENV_SCHEDULE); + final String rawRegistry = firstOf(reader, PROPERTY_REGISTRY, ENV_REGISTRY); + final String rawAttach = firstOf(reader, PROPERTY_ATTACH_BLOCK, ENV_ATTACH_BLOCK); + + if ((rawSchedule == null) != (rawRegistry == null)) { + throw new IllegalStateException( + "AERE-PQC-COMMIT-CONF-03: " + PROPERTY_SCHEDULE + " and " + PROPERTY_REGISTRY + + " are a PAIR; configure both or neither. Half a hybrid configuration must" + + " refuse at startup, never run half-armed in silence."); + } + if (rawSchedule == null) { + if (rawAttach != null) { + throw new IllegalStateException( + "AERE-PQC-HYBRID-CONF-04: " + PROPERTY_ATTACH_BLOCK + " is set but the schedule and" + + " registry are not: this node would EMIT seals nobody can verify."); + } + return new HybridSealSupport(null, null, HybridSealProducer.disarmed()); + } + + final PqSchemeSchedule schedule; + try { + schedule = PqSchemeSchedule.parse(rawSchedule); + } catch (final RuntimeException e) { + throw new IllegalStateException( + "AERE-PQC-HYBRID-CONF-04: unparseable " + PROPERTY_SCHEDULE + ": " + e.getMessage()); + } + final HybridSignerRegistry registry; + try { + final Properties p = new Properties(); + p.load( + new java.io.StringReader( + new String(reader.file(rawRegistry), StandardCharsets.UTF_8))); + registry = HybridSignerRegistry.fromProperties(p, rawRegistry); + } catch (final IOException e) { + throw new IllegalStateException( + "AERE-PQC-HYBRID-CONF-04: cannot read " + PROPERTY_REGISTRY + " '" + rawRegistry + + "': " + e.getMessage()); + } + + long attachFrom = HybridSealProducer.NEVER; + if (rawAttach != null) { + try { + attachFrom = Long.parseLong(rawAttach.trim()); + if (attachFrom < 0) { + throw new NumberFormatException("negative"); + } + } catch (final NumberFormatException e) { + throw new IllegalStateException( + "AERE-PQC-HYBRID-CONF-04: " + PROPERTY_ATTACH_BLOCK + + " is set but not a non-negative height: '" + rawAttach + "'"); + } + } + + // Local private keys, one file per non-Falcon scheme the schedule ever names. + final Map keys = new HashMap<>(); + Integer boundIndex = null; + for (final SealScheme scheme : SealSchemes.all()) { + if (scheme.id().equals(SealSchemes.FALCON_512.id())) { + continue; // Falcon-ul are incarcatorul lui, neatins + } + final String keyPath = reader.property(PROPERTY_KEY_PREFIX + scheme.id()); + if (keyPath == null) { + continue; + } + final int index; + final SealScheme.PrivateHandle handle; + try { + final Properties kp = new Properties(); + kp.load( + new java.io.StringReader( + new String(reader.file(keyPath), StandardCharsets.UTF_8))); + index = Integer.parseInt(kp.getProperty("index", "").trim()); + final byte[] sk = + Bytes.fromHexStringLenient(kp.getProperty("sk", "").trim()).toArray(); + handle = + scheme + .parsePrivateKey(sk) + .orElseThrow( + () -> new IllegalStateException("bytes do not parse as a private key")); + } catch (final IOException | RuntimeException e) { + throw new IllegalStateException( + "AERE-PQC-HYBRID-CONF-05: cannot load the local " + scheme.id() + " key from '" + + keyPath + "': " + e.getMessage()); + } + // THE LOADER'S POSITIVE CONTROL: the private key must pass its own probe against the + // PUBLIC key the registry holds for this index. A key that fails it must not boot a + // node that believes itself armed. + final Optional pub = registry.publicKey(index, scheme.id()); + if (pub.isEmpty()) { + throw new IllegalStateException( + "AERE-PQC-HYBRID-CONF-05: the registry holds no " + scheme.id() + " key for index " + + index + " (from '" + keyPath + "')"); + } + final byte[] probe = ("AERE-HYBRID-KEY-PROBE:" + index).getBytes(StandardCharsets.UTF_8); + final Optional sig = scheme.sign(handle, probe); + if (sig.isEmpty() || !scheme.verifyRaw(pub.get(), probe, sig.get())) { + throw new IllegalStateException( + "AERE-PQC-HYBRID-CONF-05: the local " + scheme.id() + " key at index " + index + + " does NOT verify against the registry's public key. Wrong key, wrong index," + + " or wrong registry; refusing to start half-armed."); + } + if (boundIndex != null && boundIndex != index) { + throw new IllegalStateException( + "AERE-PQC-HYBRID-CONF-05: local hybrid keys disagree on the validator index (" + + boundIndex + " vs " + index + "). One node, one identity."); + } + boundIndex = index; + keys.put(scheme.id(), handle); + LOG.info( + "AERE HIBRID: loaded local {} signing key for validator index {} (probe verified" + + " against the registry)", + scheme.id(), + index); + } + + if (attachFrom != HybridSealProducer.NEVER && keys.isEmpty()) { + throw new IllegalStateException( + "AERE-PQC-HYBRID-CONF-04: emission is armed from " + attachFrom + " but this node" + + " holds no local hybrid key (" + PROPERTY_KEY_PREFIX + " unset)." + + " It would promise a certificate it cannot produce."); + } + + final HybridSealProducer producer = + keys.isEmpty() + ? HybridSealProducer.disarmed() + : new HybridSealProducer(attachFrom, schedule, boundIndex, keys); + return new HybridSealSupport(schedule, registry, producer); + } + + private static String firstOf(final ConfigReader r, final String prop, final String env) { + final String p = r.property(prop); + return p != null ? p : r.environment(env); + } + + /** Returns the schedule, when the hybrid pair is configured. + * + * @return the schedule, or empty */ + public Optional schedule() { + return Optional.ofNullable(schedule); + } + + /** Returns the registry, when the hybrid pair is configured. + * + * @return the registry, or empty */ + public Optional registry() { + return Optional.ofNullable(registry); + } + + /** Returns the producer; disarmed (never emits) when nothing is configured. + * + * @return the producer */ + public HybridSealProducer producer() { + return producer; + } +} diff --git a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/HybridSignerRegistry.java b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/HybridSignerRegistry.java new file mode 100755 index 000000000..49563b7b1 --- /dev/null +++ b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/HybridSignerRegistry.java @@ -0,0 +1,307 @@ +/* + * AERE crypto-agility, step 3: the hybrid signer registry. + * + * WHY. The live registry format holds ONE Falcon key per validator index (896-byte h, plus the + * 20-byte address). The founder-approved hybrid (2026-08-07, option 3) needs a registry that can + * hold a key PER SCHEME per validator, so a certificate can carry Falcon and SLH-DSA seals from + * the same validator and each can be checked against its own key. + * + * FORMAT (properties): + * formatVersion=hybrid-1 + * chainId= + * count= + * .addr=<20-byte hex> mandatory for every index 0..count-1 + * .key.= at least one per index; schemeId from SealSchemes + * + * STRICTNESS, learned the expensive way (blocante_armare 2026-08-06: "a mistyped comma boots + * the node DISARMED"): every deviation REFUSES the whole registry loudly - unknown scheme suffix, + * wrong key length for its scheme, a hole in the index sequence, a count that disagrees, a + * missing address, duplicate keys. A registry that loads "partially" is a node that validates + * differently from its peers without knowing it. + * + * NO REAL KEYS. This class never generates anything. Real hybrid validator keys require the + * founder-approved ceremony; tests feed it throwaway pairs from SealScheme.generate. + */ +package org.hyperledger.besu.consensus.common.bft; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.NavigableMap; +import java.util.Properties; +import java.util.TreeMap; + +import org.apache.tuweni.bytes.Bytes; +import org.apache.tuweni.bytes.Bytes32; +import org.hyperledger.besu.crypto.Hash; + +/** The scheme-aware validator key registry for the hybrid certificate. Immutable once loaded. */ +public final class HybridSignerRegistry { + + /** The exact format marker this loader accepts. */ + public static final String FORMAT_VERSION = "hybrid-1"; + + /** Canonical-hash domain. Distinct from AERE-PQ-REGISTRY-1/-2 (the Falcon-only registry hash + * family in PqRegistryHash), so a hybrid registry hash can never be mistaken for a v1/v2 one. */ + public static final String HASH_DOMAIN = "AERE-PQ-HYBRID-REGISTRY-1"; + + private final long chainId; + // index -> (schemeId -> key bytes); TreeMap so iteration is canonical by index + private final NavigableMap> keys; + private final Map addresses; + + private HybridSignerRegistry( + final long chainId, + final NavigableMap> keys, + final Map addresses) { + this.chainId = chainId; + this.keys = keys; + this.addresses = addresses; + } + + /** Load from a properties file on disk. Refuses loudly, never partially. */ + public static HybridSignerRegistry load(final Path file) throws IOException { + final Properties p = new Properties(); + try (InputStream in = Files.newInputStream(file)) { + p.load(in); + } + return fromProperties(p, file.toString()); + } + + /** Load from already-parsed properties. {@code source} names the origin for error messages. */ + public static HybridSignerRegistry fromProperties(final Properties p, final String source) { + final String format = p.getProperty("formatVersion"); + if (!FORMAT_VERSION.equals(format)) { + throw new IllegalArgumentException( + "AERE PQ HIBRID: " + source + " declares formatVersion=" + format + + ", this loader accepts only " + FORMAT_VERSION); + } + final long chainId = parseLong(p.getProperty("chainId"), "chainId", source); + final int count = (int) parseLong(p.getProperty("count"), "count", source); + if (count <= 0 || count > 1024) { + throw new IllegalArgumentException( + "AERE PQ HIBRID: " + source + " has count=" + count + ", outside (0, 1024]"); + } + + final NavigableMap> keys = new TreeMap<>(); + final Map addresses = new HashMap<>(); + + for (final String name : p.stringPropertyNames()) { + if (name.equals("formatVersion") || name.equals("chainId") || name.equals("count")) { + continue; + } + final int dot = name.indexOf('.'); + if (dot <= 0) { + throw new IllegalArgumentException( + "AERE PQ HIBRID: " + source + " carries unrecognised entry '" + name + "'"); + } + final int index = parseIndex(name.substring(0, dot), name, source); + final String rest = name.substring(dot + 1); + final byte[] value = decodeHex(p.getProperty(name), name, source); + + if (rest.equals("addr")) { + if (value.length != 20) { + throw new IllegalArgumentException( + "AERE PQ HIBRID: " + source + " entry '" + name + "' is " + value.length + + " bytes, an address must be exactly 20"); + } + if (addresses.put(index, value) != null) { + throw new IllegalArgumentException( + "AERE PQ HIBRID: " + source + " repeats address for index " + index); + } + } else if (rest.startsWith("key.")) { + final String schemeId = rest.substring("key.".length()); + final SealScheme scheme = + SealSchemes.byId(schemeId) + .orElseThrow( + () -> + new IllegalArgumentException( + "AERE PQ HIBRID: " + source + " entry '" + name + + "' names UNKNOWN scheme '" + schemeId + + "' - refusing the whole registry, an unknown scheme must be loud")); + if (value.length != scheme.publicKeyLength()) { + throw new IllegalArgumentException( + "AERE PQ HIBRID: " + source + " entry '" + name + "' is " + value.length + + " bytes, scheme " + schemeId + " keys are exactly " + + scheme.publicKeyLength()); + } + if (scheme.parsePublicKey(value).isEmpty()) { + throw new IllegalArgumentException( + "AERE PQ HIBRID: " + source + " entry '" + name + + "' does not parse as a " + schemeId + " public key"); + } + final Map perScheme = keys.computeIfAbsent(index, i -> new TreeMap<>()); + if (perScheme.put(schemeId, value) != null) { + throw new IllegalArgumentException( + "AERE PQ HIBRID: " + source + " repeats key for index " + index + + " scheme " + schemeId); + } + } else { + throw new IllegalArgumentException( + "AERE PQ HIBRID: " + source + " carries unrecognised entry '" + name + "'"); + } + } + + // completeness: every index 0..count-1 present, with an address and at least one key + for (int i = 0; i < count; i++) { + if (!addresses.containsKey(i)) { + throw new IllegalArgumentException( + "AERE PQ HIBRID: " + source + " is missing " + i + ".addr (count says " + count + ")"); + } + if (!keys.containsKey(i) || keys.get(i).isEmpty()) { + throw new IllegalArgumentException( + "AERE PQ HIBRID: " + source + " has no key at all for index " + i); + } + } + if (addresses.size() != count || keys.size() != count) { + throw new IllegalArgumentException( + "AERE PQ HIBRID: " + source + " carries entries beyond count=" + count + + " (addresses " + addresses.size() + ", key rows " + keys.size() + ")"); + } + + return new HybridSignerRegistry(chainId, keys, addresses); + } + + /** The chain this registry binds to. */ + public long chainId() { + return chainId; + } + + /** How many validator indices the registry holds. */ + public int size() { + return keys.size(); + } + + /** The key of {@code index} under {@code schemeId}, if that validator has one. */ + public Optional publicKey(final int index, final String schemeId) { + final Map perScheme = keys.get(index); + if (perScheme == null) { + return Optional.empty(); + } + final byte[] exact = perScheme.get(schemeId); + if (exact != null) { + return Optional.of(exact.clone()); + } + // D-325 (2026-09-03): a registry written with an ALIAS of a scheme id ("slh-dsa-128s") answers a + // lookup by the canonical id ("slh-dsa-sha2-128s") and the other way round. Stored names are + // kept as written so the registry's canonical hash does not move under a file that did not. + final String wanted = canonicalId(schemeId); + for (final Map.Entry e : perScheme.entrySet()) { + if (canonicalId(e.getKey()).equals(wanted)) { + return Optional.of(e.getValue().clone()); + } + } + return Optional.empty(); + } + + private static String canonicalId(final String schemeId) { + return SealSchemes.byId(schemeId).map(SealScheme::id).orElse(schemeId); + } + + /** The 20-byte address bound to {@code index}, or empty. */ + public Optional address(final int index) { + return Optional.ofNullable(addresses.get(index)).map(byte[]::clone); + } + + /** How many indices hold a key under {@code schemeId}. The arming gate for a scheme asks this: + * arming a K-of-N threshold under a scheme with coverage below K would be a chain stop. */ + public int coverage(final String schemeId) { + final String wanted = canonicalId(schemeId); + return (int) + keys.values().stream() + .filter(m -> m.keySet().stream().anyMatch(k -> canonicalId(k).equals(wanted))) + .count(); + } + + /** The canonical hash: domain || chainId || count || per index asc: index, addr, schemeCount, + * then per scheme in id order: idLen, idBytes, keyLen, key. Length-prefixed throughout, keccak + * over the whole, same discipline as PqRegistryHash. */ + public Bytes32 canonicalHash() { + final java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream(); + writeAll(out, HASH_DOMAIN.getBytes(StandardCharsets.US_ASCII)); + writeAll(out, uint64be(chainId)); + writeAll(out, uint32be(keys.size())); + for (final Map.Entry> row : keys.entrySet()) { + writeAll(out, uint32be(row.getKey())); + writeAll(out, addresses.get(row.getKey())); + writeAll(out, uint32be(row.getValue().size())); + for (final Map.Entry k : row.getValue().entrySet()) { + final byte[] id = k.getKey().getBytes(StandardCharsets.US_ASCII); + writeAll(out, uint32be(id.length)); + writeAll(out, id); + writeAll(out, uint32be(k.getValue().length)); + writeAll(out, k.getValue()); + } + } + return Hash.keccak256(Bytes.wrap(out.toByteArray())); + } + + /** The schemes present for {@code index}, in canonical id order. */ + public List schemesOf(final int index) { + final Map perScheme = keys.get(index); + return perScheme == null ? List.of() : new ArrayList<>(perScheme.keySet()); + } + + // ------------------------------------------------------------------------------- helpers + + private static long parseLong(final String raw, final String field, final String source) { + if (raw == null || raw.isBlank()) { + throw new IllegalArgumentException("AERE PQ HIBRID: " + source + " is missing " + field); + } + try { + return Long.parseLong(raw.trim()); + } catch (final NumberFormatException e) { + throw new IllegalArgumentException( + "AERE PQ HIBRID: " + source + " field " + field + " is not a number: '" + raw + "'"); + } + } + + private static int parseIndex(final String raw, final String entry, final String source) { + try { + final int i = Integer.parseInt(raw); + if (i < 0) { + throw new NumberFormatException("negative"); + } + return i; + } catch (final NumberFormatException e) { + throw new IllegalArgumentException( + "AERE PQ HIBRID: " + source + " entry '" + entry + "' has a bad index '" + raw + "'"); + } + } + + private static byte[] decodeHex(final String raw, final String entry, final String source) { + if (raw == null || raw.isBlank()) { + throw new IllegalArgumentException( + "AERE PQ HIBRID: " + source + " entry '" + entry + "' is empty"); + } + try { + return Bytes.fromHexStringLenient(raw.trim()).toArray(); + } catch (final RuntimeException e) { + throw new IllegalArgumentException( + "AERE PQ HIBRID: " + source + " entry '" + entry + "' is not hex: " + e.getMessage()); + } + } + + private static void writeAll(final java.io.ByteArrayOutputStream out, final byte[] b) { + out.write(b, 0, b.length); + } + + private static byte[] uint32be(final long v) { + return new byte[] {(byte) (v >>> 24), (byte) (v >>> 16), (byte) (v >>> 8), (byte) v}; + } + + private static byte[] uint64be(final long v) { + final byte[] b = new byte[8]; + for (int i = 0; i < 8; i++) { + b[i] = (byte) (v >>> (8 * (7 - i))); + } + return b; + } +} diff --git a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchor.java b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchor.java new file mode 100755 index 000000000..32c634ee8 --- /dev/null +++ b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchor.java @@ -0,0 +1,547 @@ +/* + * Copyright contributors to Besu / AERE Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +import org.hyperledger.besu.crypto.Hash; +import org.hyperledger.besu.ethereum.rlp.BytesValueRLPOutput; +import org.hyperledger.besu.ethereum.rlp.RLPOutput; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.List; + +import org.apache.tuweni.bytes.Bytes; +import org.apache.tuweni.bytes.Bytes32; + +/** + * CERTIFICATE ANCHOR, schema V2: "IT IS CARRIED, NOT REFERENCED". + * + *

Canonical encoding, anchor digest D and signed message M for the AERE post-quantum certificate + * anchor. This class is a PURE function library: no singletons, no configuration, no I/O, no state. + * Everything it computes is a function of its arguments only, which is what makes {@code + * PqAnchorDigestRule} usable in LIGHT validation. + * + *

Why V2 exists. Today the QBFT block hash covers no proof of any post-quantum seal: the + * Falcon seal list sits in {@code EncodingType.ALL} only, and both hash pre-images exclude it. You + * can strip the entire post-quantum attestation from a header and its block hash is unchanged. The + * earlier design (Option V) tried to fix that by putting the digest of the PARENT's certificate, + * read out of the PARENT's header, into the child's vanityData. That died on a measurement: over 200 + * consecutive headers read simultaneously from two of our own RPC nodes, with the block hash + * identical 200/200, the seal ORDER differed on 68 headers and the seal SET differed on 11. The + * cause is structural, not a bug to patch: {@code RoundState} holds commits in a {@code + * LinkedHashMap} keyed on author, {@code values()} yields arrival order, and the assembler does not + * reorder. Every node writes what IT heard. Sorting fixes the order and does NOT fix the set, and no + * deterministic function of a local set can produce the same output from two different sets. + * + *

What V2 does instead. The certificate over block N-1 is not read out of N-1's header. + * It is CARRIED in the extraData of block N, chosen by N's proposer and ratified by N's round, and + * N's vanityData carries its digest over all 32 bytes. The digest therefore depends only on what the + * PROPOSER WROTE, and those bytes reach every node identically inside the same proposal. A validator + * never compares the certificate with what it heard: it checks only the threshold, strictly + * increasing indices, index eligibility in the PARENT's validator set, and k Falcon verifications. A + * node that heard 7 accepts the proposer's certificate of 5. + * + *

The two pre-images. + * + *

+ *   D = keccak256( RLP[ "AERE-PQ-ANCHOR-1", chainId, parentNumber, parentHash, C ] )
+ *   M = keccak256( RLP[ "AERE-PQ-COMMIT-1", chainId, blockNumber,  blockHash        ] )
+ *   C = RLP[ [idx, sig], [idx, sig], ... ]     indices STRICTLY INCREASING
+ * 
+ * + *

D occupies all 32 bytes of vanityData: no magic, no version byte, no padding. The budget is 32 + * bytes and cannot grow, because two LIVE length checks require exactly 32 (AereQbftFinalityVerifier + * .sol line 194 and the second client's decoder). Versioning is by ACTIVATION HEIGHT, not by an + * in-band version byte, because any format change needs a governance-decided height anyway. + * + *

M is deliberately NOT the ECDSA commit hash. The commit hash includes the ROUND, which is not + * in the block-hash pre-image, and rounds above zero do occur on this chain (measured 1 in 307). + * Defining M over the block HASH makes the message reconstructible by any node holding the parent + * header: no state, no round, no local memory. + * + *

The domain labels are what stops a Falcon signature produced for another subsystem (the + * AerePQCTxAccount rail, ML-KEM intents, any application message) from being reinterpreted as a + * consensus seal, and the chainId is what stops a certificate produced on the 442807 proving chain, + * which runs the SAME binaries and may run the same Falcon keys, from being material here. + * + *

CONSENSUS IS STILL CLASSICAL secp256k1 ECDSA. This class does not make consensus post-quantum + * and must never be described as doing so. The post-quantum layer is signature, precompile and + * account level. + */ +public final class PqAnchor { + + /** Domain label for the anchor digest D. Exactly 16 ASCII bytes. */ + public static final String ANCHOR_DOMAIN = "AERE-PQ-ANCHOR-1"; + + /** Domain label for the signed commit message M. Exactly 16 ASCII bytes. */ + public static final String COMMIT_DOMAIN = "AERE-PQ-COMMIT-1"; + + /** The anchor domain label as raw bytes. */ + public static final Bytes ANCHOR_DOMAIN_BYTES = + Bytes.wrap(ANCHOR_DOMAIN.getBytes(StandardCharsets.US_ASCII)); + + /** The commit domain label as raw bytes. */ + public static final Bytes COMMIT_DOMAIN_BYTES = + Bytes.wrap(COMMIT_DOMAIN.getBytes(StandardCharsets.US_ASCII)); + + /** + * The PREPARE domain label. AERE PQ (2026-08-28). + * + *

SEPARATE FROM COMMIT, and the separation is a security requirement, not a matter of style. + * If a PREPARE seal signed the same bytes as a commit seal, an adversary could take a PREPARE + * seal given HONESTLY by a validator and paste it onto a forged COMMIT: the signature would + * verify, and the very rule meant to defend the commit would be bypassed. One changed string in + * the preimage makes the two signatures non-transferable. + */ + public static final String PREPARE_DOMAIN = "AERE-PQ-PREPARE-1"; + + /** The prepare domain label as raw bytes. */ + public static final Bytes PREPARE_DOMAIN_BYTES = + Bytes.wrap(PREPARE_DOMAIN.getBytes(StandardCharsets.US_ASCII)); + + /** + * The PROPOSAL domain label. AERE PQ (2026-08-30), the next hot-path step after PREPARE. + * + *

SEPARATE FROM BOTH PREPARE AND COMMIT, for the same non-transferability reason: a seal a + * proposer gives HONESTLY over its own proposal must not be usable as a vote. A proposal is an + * OFFER, not a vote - the design note of 2026-08-28 measures that "prepared" needs a full quorum + * of PREPAREs, proposer included - so its seal must never count as one. One changed string in the + * preimage is what enforces that at the cryptographic layer instead of by convention. + */ + public static final String PROPOSAL_DOMAIN = "AERE-PQ-PROPOSAL-1"; + + /** The proposal domain label as raw bytes. */ + public static final Bytes PROPOSAL_DOMAIN_BYTES = + Bytes.wrap(PROPOSAL_DOMAIN.getBytes(StandardCharsets.US_ASCII)); + + /** + * The ROUND-CHANGE domain label. AERE PQ (2026-08-31), the last hot-path message. + * + *

SEPARATE FROM ALL THREE OTHERS, for the same non-transferability reason. A ROUND-CHANGE is + * the message that STEERS rounds: a quorum of them opens a new round, and one that claims a + * prepared block decides WHICH block gets re-proposed. A seal given honestly over a vote or a + * proposal must not be pasteable onto a round-change, and a round-change seal must not count as + * either. One changed string in the preimage enforces that cryptographically. + */ + public static final String ROUNDCHANGE_DOMAIN = "AERE-PQ-ROUNDCHANGE-1"; + + /** The round-change domain label as raw bytes. */ + public static final Bytes ROUNDCHANGE_DOMAIN_BYTES = + Bytes.wrap(ROUNDCHANGE_DOMAIN.getBytes(StandardCharsets.US_ASCII)); + + /** Orders Falcon seals by their registry index, ascending. */ + public static final Comparator BY_INDEX = + Comparator.comparingInt(FalconSeal::getValidatorIndex); + + private PqAnchor() {} + + /** + * Encode the certificate C in its canonical wire form. This is byte-for-byte the shape {@code + * QbftExtraDataCodec} already writes for the Falcon seal element, so the certificate that is + * hashed is the certificate that is carried: one logical object, one accepted byte string. + * + * @param output the RLP sink to write the certificate list into + * @param certificate the seals, in the order they are to be encoded + */ + public static void writeCertificate( + final RLPOutput output, final Collection certificate) { + output.writeList( + certificate, + (seal, rlp) -> { + rlp.startList(); + rlp.writeIntScalar(seal.getValidatorIndex()); + rlp.writeBytes(seal.getSignature()); + rlp.endList(); + }); + } + + /** + * The canonical RLP encoding of the certificate C on its own. Used by tests and diagnostics; the + * digest path writes the certificate directly into the outer list rather than nesting an encoded + * blob. + * + * @param certificate the seals, in the order they are to be encoded + * @return the canonical RLP bytes of C + */ + public static Bytes encodeCertificate(final Collection certificate) { + final BytesValueRLPOutput out = new BytesValueRLPOutput(); + writeCertificate(out, certificate); + return out.encoded(); + } + + /** + * Compute the anchor digest D that block N's vanityData must carry, over the certificate block N + * carries for its PARENT. + * + *

The certificate is hashed IN THE ORDER GIVEN. It is deliberately not sorted here: sorting + * inside the digest would let a header with a reordered certificate still match D, which would + * make the digest rule (cheap, runs in light validation) disagree with the seals rule (expensive, + * runs only where state exists). Instead BOTH rules require {@link + * #hasStrictlyIncreasingIndices(Collection)}, so for every ACCEPTED header "the order given" and + * "sorted" are the same list, and reordering is rejected in light validation too. Producers call + * {@link #sortedByIndex(Collection)} before writing. + * + * @param chainId the chain id, in the pre-image so a certificate from another chain running the + * same binaries and possibly the same Falcon keys is not material here + * @param parentNumber the number of the parent block the certificate attests to + * @param parentHash the hash of the parent block the certificate attests to + * @param certificate the carried certificate C, possibly empty (at the activation height itself) + * @return the 32-byte anchor digest D + */ + public static Bytes32 anchorDigest( + final long chainId, + final long parentNumber, + final Bytes parentHash, + final Collection certificate) { + if (parentNumber < 0) { + throw new IllegalArgumentException( + "AERE PQ ANCHOR: parentNumber must not be negative (got " + parentNumber + ")"); + } + if (parentHash == null || parentHash.size() != 32) { + throw new IllegalArgumentException( + "AERE PQ ANCHOR: parentHash must be 32 bytes (got " + + (parentHash == null ? "null" : parentHash.size() + " bytes") + + ")"); + } + final BytesValueRLPOutput out = new BytesValueRLPOutput(); + out.startList(); + out.writeBytes(ANCHOR_DOMAIN_BYTES); + out.writeLongScalar(chainId); + out.writeLongScalar(parentNumber); + out.writeBytes(parentHash); + writeCertificate(out, certificate); + out.endList(); + return Hash.keccak256(out.encoded()); + } + + /** + * Compute the message M that a validator's Falcon key signs when it seals a block. + * + *

M is a function of the block HASH, never of the commit hash, so it can be recomputed from the + * header alone: no round number, no local state, no memory of the round that produced it. + * + * @param chainId the chain id + * @param blockNumber the number of the block being sealed + * @param blockHash the hash of the block being sealed + * @return the 32-byte message M that Falcon signs + */ + public static Bytes32 commitMessage( + final long chainId, final long blockNumber, final Bytes blockHash) { + if (blockNumber < 0) { + throw new IllegalArgumentException( + "AERE PQ ANCHOR: blockNumber must not be negative (got " + blockNumber + ")"); + } + if (blockHash == null || blockHash.size() != 32) { + throw new IllegalArgumentException( + "AERE PQ ANCHOR: blockHash must be 32 bytes (got " + + (blockHash == null ? "null" : blockHash.size() + " bytes") + + ")"); + } + final BytesValueRLPOutput out = new BytesValueRLPOutput(); + out.startList(); + out.writeBytes(COMMIT_DOMAIN_BYTES); + out.writeLongScalar(chainId); + out.writeLongScalar(blockNumber); + out.writeBytes(blockHash); + out.endList(); + return Hash.keccak256(out.encoded()); + } + + /** + * The message a PREPARE seal signs: M = keccak256(RLP[PREPARE_DOMAIN, chainId, blockNumber, + * round, digest]). + * + *

RUNDA E IN PREIMAGINE, spre deosebire de commit, si asta e al doilea lucru care nu se sare: + * doua PREPARE-uri ale aceluiasi bloc in runde diferite sunt doua afirmatii diferite, iar un + * a seal given in one round must not be movable into another. Without the round, a seal from a + * PREPARE of a failed round could be reused to justify another one. + * + * @param chainId the chain id + * @param blockNumber the height being prepared + * @param round the round number of the prepare + * @param digest the block digest the prepare speaks about + * @return the 32-byte message to sign + */ + public static Bytes32 prepareMessage( + final long chainId, final long blockNumber, final int round, final Bytes digest) { + if (blockNumber < 0) { + throw new IllegalArgumentException( + "AERE PQ PREPARE: blockNumber must not be negative (got " + blockNumber + ")"); + } + if (round < 0) { + throw new IllegalArgumentException( + "AERE PQ PREPARE: round must not be negative (got " + round + ")"); + } + if (digest == null || digest.size() != 32) { + throw new IllegalArgumentException( + "AERE PQ PREPARE: digest must be 32 bytes (got " + + (digest == null ? "null" : digest.size() + " bytes") + + ")"); + } + final BytesValueRLPOutput out = new BytesValueRLPOutput(); + out.startList(); + out.writeBytes(PREPARE_DOMAIN_BYTES); + out.writeLongScalar(chainId); + out.writeLongScalar(blockNumber); + out.writeLongScalar(round); + out.writeBytes(digest); + out.endList(); + return Hash.keccak256(out.encoded()); + } + + /** + * The 32-byte message a proposer's post-quantum PROPOSAL seal signs. + * + *

Same shape as {@link #prepareMessage}, different domain, and the ROUND is in the preimage + * for the same reason: a proposal for the same block in a different round is a different + * assertion, and a seal from a failed round must not open another one. + * + * @param chainId the chain id + * @param blockNumber the height being proposed + * @param round the round number of the proposal + * @param digest the digest of the proposed block + * @return the 32-byte message to sign + */ + public static Bytes32 proposalMessage( + final long chainId, final long blockNumber, final int round, final Bytes digest) { + if (blockNumber < 0) { + throw new IllegalArgumentException( + "AERE PQ PROPOSAL: blockNumber must not be negative (got " + blockNumber + ")"); + } + if (round < 0) { + throw new IllegalArgumentException( + "AERE PQ PROPOSAL: round must not be negative (got " + round + ")"); + } + if (digest == null || digest.size() != 32) { + throw new IllegalArgumentException( + "AERE PQ PROPOSAL: digest must be 32 bytes (got " + + (digest == null ? "null" : digest.size() + " bytes") + + ")"); + } + final BytesValueRLPOutput out = new BytesValueRLPOutput(); + out.startList(); + out.writeBytes(PROPOSAL_DOMAIN_BYTES); + out.writeLongScalar(chainId); + out.writeLongScalar(blockNumber); + out.writeLongScalar(round); + out.writeBytes(digest); + out.endList(); + return Hash.keccak256(out.encoded()); + } + + /** + * The 32-byte message a validator's post-quantum ROUND-CHANGE seal signs, for a round-change + * that carries NO prepared-round metadata. + * + *

The preimage writes an explicit presence flag of 0 and empty placeholders, so a bare + * round-change and one prepared at round 0 can never collide: the flag, not the emptiness of a + * field, is what says whether metadata exists. + * + * @param chainId the chain id + * @param blockNumber the height the round-change targets (its sequence number) + * @param targetRound the round the sender wants to move to + * @return the 32-byte message to sign + */ + public static Bytes32 roundChangeMessage( + final long chainId, final long blockNumber, final int targetRound) { + return roundChangePreimage(chainId, blockNumber, targetRound, false, 0, Bytes.EMPTY); + } + + /** + * The 32-byte message a validator's post-quantum ROUND-CHANGE seal signs, for a round-change + * that CLAIMS a prepared block. + * + *

The prepared metadata is IN the preimage on purpose: a round-change that claims a prepared + * block decides which block gets re-proposed, so a seal from a bare round-change pasted onto one + * with metadata (or onto one with different metadata) must not verify. Same shape as {@link + * #proposalMessage}, its own domain. + * + * @param chainId the chain id + * @param blockNumber the height the round-change targets (its sequence number) + * @param targetRound the round the sender wants to move to + * @param preparedRound the round the claimed prepared block was prepared in + * @param preparedDigest the 32-byte digest of the claimed prepared block + * @return the 32-byte message to sign + */ + public static Bytes32 roundChangeMessage( + final long chainId, + final long blockNumber, + final int targetRound, + final int preparedRound, + final Bytes preparedDigest) { + if (preparedRound < 0) { + throw new IllegalArgumentException( + "AERE PQ ROUNDCHANGE: preparedRound must not be negative (got " + preparedRound + ")"); + } + if (preparedDigest == null || preparedDigest.size() != 32) { + throw new IllegalArgumentException( + "AERE PQ ROUNDCHANGE: preparedDigest must be 32 bytes (got " + + (preparedDigest == null ? "null" : preparedDigest.size() + " bytes") + + ")"); + } + return roundChangePreimage(chainId, blockNumber, targetRound, true, preparedRound, preparedDigest); + } + + private static Bytes32 roundChangePreimage( + final long chainId, + final long blockNumber, + final int targetRound, + final boolean hasPrepared, + final int preparedRound, + final Bytes preparedDigest) { + if (blockNumber < 0) { + throw new IllegalArgumentException( + "AERE PQ ROUNDCHANGE: blockNumber must not be negative (got " + blockNumber + ")"); + } + if (targetRound < 0) { + throw new IllegalArgumentException( + "AERE PQ ROUNDCHANGE: targetRound must not be negative (got " + targetRound + ")"); + } + final BytesValueRLPOutput out = new BytesValueRLPOutput(); + out.startList(); + out.writeBytes(ROUNDCHANGE_DOMAIN_BYTES); + out.writeLongScalar(chainId); + out.writeLongScalar(blockNumber); + out.writeLongScalar(targetRound); + out.writeLongScalar(hasPrepared ? 1 : 0); + out.writeLongScalar(preparedRound); + out.writeBytes(preparedDigest); + out.endList(); + return Hash.keccak256(out.encoded()); + } + + /** + * Whether the certificate's validator indices are STRICTLY increasing. + * + *

Strictly increasing does three jobs at once and all three are structural rather than + * defensive: it fixes ONE accepted order for a given set (closing seal reordering), it makes + * duplicate indices unrepresentable (closing the "inflate k by repeating a seal" attack in the + * grammar of the format rather than with a HashSet applied afterwards), and it rejects negative + * indices when combined with the non-negative check below. + * + * @param certificate the carried certificate + * @return true iff every index is non-negative and each is strictly greater than its predecessor + */ + public static boolean hasStrictlyIncreasingIndices(final Collection certificate) { + long previous = Long.MIN_VALUE; + for (final FalconSeal seal : certificate) { + if (seal == null || seal.getSignature() == null) { + return false; + } + final int index = seal.getValidatorIndex(); + if (index < 0 || index <= previous) { + return false; + } + previous = index; + } + return true; + } + + /** + * First anchor height of the one historical window on chain 2800 whose certificates carry indices + * that are distinct but NOT sorted. Measured 2026-08-11 by decoding every anchor header in + * [13,266,800, 13,269,400] straight off the public endpoint: 82 anchor headers, 46 sorted, 36 not, + * none without a certificate. The 36 are CONTIGUOUS at the 32-block anchor spacing and nothing + * outside the window is affected, so the range below is the measured extent, not a guess. + * + *

DERIVED, not written a second time. The bounds themselves live in {@link PqAnchorLapse}, + * which is the one place a historical window is named, because the SAME interruption that left + * these certificates unsorted also left their headers without an anchor digest, and a value that + * appears in two places diverges. This constant is kept because it is the name this class has + * always used for the first bound. + */ + public static final long UNORDERED_WINDOW_FIRST = PqAnchorLapse.windows().get(0).firstBlock(); + + /** Last anchor height of that window. Its neighbours 13,267,792 and 13,268,976 are both sorted. */ + public static final long UNORDERED_WINDOW_LAST = PqAnchorLapse.windows().get(0).lastBlock(); + + /** + * Whether the certificate's indices are non-negative and pairwise DISTINCT, in any order. + * + *

This is the part of {@link #hasStrictlyIncreasingIndices(Collection)} that is load bearing + * against an attacker rather than against ambiguity. Sortedness fixes one accepted order for a + * given set; distinctness is what makes "inflate k by repeating one seal" unrepresentable in the + * grammar of the format. A historical exception may relax the first WITHOUT relaxing the second, + * and this method exists so that the exception cannot accidentally relax both. + * + * @param certificate the carried certificate + * @return true iff every index is non-negative and no index appears twice + */ + public static boolean hasDistinctNonNegativeIndices(final Collection certificate) { + final java.util.Set seen = new java.util.HashSet<>(); + for (final FalconSeal seal : certificate) { + if (seal == null || seal.getSignature() == null) { + return false; + } + final int index = seal.getValidatorIndex(); + if (index < 0 || !seen.add(index)) { + return false; + } + } + return true; + } + + /** + * Whether a header at {@code blockNumber} may carry this certificate's index ordering. + * + *

Why this exists. During a production interruption on 2026-08-10 some proposers wrote + * the certificate in the order the commits ARRIVED instead of sorted. {@code RoundState} holds + * commits in a {@code LinkedHashMap} keyed on author and {@code values()} yields arrival order, so + * a producer that skips {@link #sortedByIndex(Collection)} writes an unsorted certificate and + * nothing downstream notices: the anchor digest is taken IN THE ORDER GIVEN, so those headers' + * digests are perfectly consistent with their own bytes. 36 such headers are canonical today. + * + *

Nodes already holding the chain never revalidate them, which is exactly why this was invisible + * for a day. A node syncing from genesis under the plain rule would reject header 13,267,824 and + * stop there forever. That is the same shape as the base-fee floor episode: the history is what it + * is, and the exception belongs at VALIDATION, where a header is accepted as written, not in any + * recomputation. A patch anywhere else moves the error instead of ending it. + * + *

What the exception does NOT relax. Inside the window the certificate must still have + * non-negative and pairwise distinct indices. Measured 2026-08-11 on all 36 headers: distinct in + * every one, no negative index in any. So this accepts exactly the history that exists and nothing + * weaker, and the "repeat one seal to inflate k" attack stays unrepresentable at every height. + * + *

Producers must not call this. {@code PqAnchorProducer} keeps requiring strict + * sortedness, so no new unsorted header can ever be written, at any height. The window is closed by + * construction: it is bounded above by a measured constant, so it cannot grow. + * + * @param certificate the carried certificate + * @param blockNumber the number of the header carrying it + * @return true iff the ordering is acceptable for a header at that height + */ + public static boolean hasAcceptableIndices( + final Collection certificate, final long blockNumber) { + if (hasStrictlyIncreasingIndices(certificate)) { + return true; + } + return PqAnchorLapse.isDisarmed(blockNumber) && hasDistinctNonNegativeIndices(certificate); + } + + /** + * Return the certificate sorted by validator index, ascending. Producers use this before writing + * the certificate into extraData; validators never sort, they require sortedness. + * + * @param certificate the seals in any order + * @return a new list sorted by validator index + */ + public static List sortedByIndex(final Collection certificate) { + final List sorted = new ArrayList<>(certificate); + sorted.sort(BY_INDEX); + return sorted; + } +} diff --git a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorConfig.java b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorConfig.java new file mode 100755 index 000000000..d04d52829 --- /dev/null +++ b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorConfig.java @@ -0,0 +1,1580 @@ +/* + * Copyright contributors to Besu / AERE Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +import java.util.Collections; +import java.util.Locale; +import java.util.Map; +import java.util.NavigableMap; +import java.util.OptionalInt; +import java.util.TreeMap; + +import com.google.common.base.Splitter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Height-indexed activation configuration for the V2 certificate anchor: {@code pqAnchorBlock} (H) + * and the staged {@code pqAnchorMinSeals} threshold schedule K(height), plus the two LOCAL emergency + * de-arm controls. + * + *

The gate is on the BLOCK NUMBER. Below H no new rule applies at all: {@link + * #activeAt(long)} returns false and both new rules return true as their first statement, before + * they decode anything. The existing chain history is therefore untouched, with the same hashes, + * because neither the hash formula, nor the codec, nor the existing rule set changes for it. That + * property is cheap precisely because the gate is on the number and not on the content, and it is + * the condition under which a binary built from this code can be warmed on a live node without risk. + * + *

Emergency de-arm. Two controls exist and are deliberately LOCAL to the node, not + * on-chain: a halted chain cannot deliver a height-scheduled configuration change, so the only + * control that works when the chain is ALREADY STOPPED is one that lives in the node's own + * configuration and takes effect on restart. + * + *

    + *
  • {@value #PROPERTY_MIN_SEALS_CEILING} lowers K, and can NEVER raise it: the effective + * threshold is {@code min(scheduled, ceiling)}. A control that could raise the threshold would + * be a way to halt the chain, not a way to recover it. + *
  • {@value #PROPERTY_DISABLE} switches both rules off entirely on this node. + *
+ * + *

Both take at least a quorum of nodes to restart before block production resumes, which is + * exactly the right threshold for a change of this weight. + * + *

AERE OPTIUNI-URGENTA (2026-08-02): both are now COMMAND-LINE OPTIONS. Until this date + * they existed only as system properties and environment variables, which meant the documented way + * back from a bad activation was to edit a service unit or a wrapper script, and the javadoc above + * named two command-line options that did not exist. They now do: {@code + * --Xaere-pq-anchor-disarm} and {@code --Xaere-pq-anchor-min-seals-max}, defined in {@code + * org.hyperledger.besu.cli.options.AerePqEmergencyOptions} and applied onto these same property + * names before anything reads them, so there is exactly ONE place that decides what is in force. The + * property and environment forms keep working unchanged; the command line wins over both, and which + * source won is logged. + * + *

AERE CONFIGURATIE-STRICTA (2026-08-06): {@link #fromSystemConfiguration()} is FAIL-CLOSED on + * PRESENCE. Until this date every reading fault in this loader ended in a value rather than in a + * refusal: an unparseable step DISCARDED the whole schedule and left K at 0 with the node ARMED; an + * unparseable ceiling was IGNORED while the command-line banner announced it as being in force; and + * an {@link IllegalArgumentException} from the constructor returned {@link #never(long)}, so the node + * ran with the anchor completely inert. None of the three stops a chain, which is exactly what makes + * them dangerous: the fleet keeps producing blocks, every head matches, every graph is green, and + * the post-quantum property we state in public is simply not being enforced. That is the Holesky + * shape - one wrong configuration field, silently defaulted, nodes that started perfectly and ran + * for months. + * + *

The gate is on PRESENCE, not on value, and that is what keeps the compatibility property + * intact: + * + *

    + *
  • No {@code aere.pq.*} name set at all - not as a system property, not as an + * environment variable - and this method returns {@link #never(long)} at its first statement. + * No decode, no allocation, no log, bit for bit today's behaviour. Absence is not a + * misconfiguration, it is the absence of a configuration, and a binary built from this code + * can still be warmed on a live node that has none of these names set. + *
  • At least one name present, even set to the empty string, and every field is read + * strictly: anything unparseable, an empty value, a discarded schedule, a threshold schedule + * with no step at H, an activation height without an explicit chain id, or a constructor that + * rejects the result, is a {@link FalconSealSupport.ActivationConfigException} carrying {@link + * #REFUSAL_CODE}, i.e. a refusal to start. An operator who typed {@code + * -Daere.pq.anchorBlock=} wanted an anchor and had a shell variable go empty on them; that is + * a mistake, not an absence. + *
+ * + *

The one deliberate exception is {@value #PROPERTY_DISABLE}. It is the brake, it is the only + * control that works when the chain is ALREADY STOPPED, and a brake that can itself be refused is + * not a brake. When it resolves to true the node STARTS: the rest of the configuration is still + * parsed, so that a valid one keeps {@link #anchorConfigured()} true and the per-block disarm + * announcement keeps shouting, and an invalid one is reported at ERROR and the node starts fully + * inert rather than not at all. + * + *

Refusing at STARTUP is not the class of failure that CrowdStrike and Cloudflare are cautionary + * tales about. Those were fail-closed at RUNTIME, delivered to an entire fleet at once. A refusal + * here happens before the node has joined the quorum, is visible in {@code systemctl status} in + * second zero, and is repaired with one line and one restart, at a pace the operator controls. The + * risk that IS real is a bad shared template plus a parallel fleet restart: at quorum 5 of 7 that is + * not degradation, it is a dead chain. The net for it is the one already written in the runbook - + * restart one at a time, never in parallel - and a preflight that computes its verdict from THIS + * code path rather than from a second reading of the same strings. + * + *

HONEST LIMITATION, stated in code because it is the same defect class as A8. The values + * here are read from LOCAL system properties or environment variables, exactly like {@code + * aere.falcon.registry} is today. They are NOT yet read from the genesis {@code config.qbft} / + * {@code config.transitions.qbft}, and there is NO consensus binding on them: two nodes configured + * with different H or different K schedules will disagree about which headers are valid. Wiring + * these to genesis, and refusing to start when the Falcon registry does not match the genesis {@code + * pqRegistryHash}, is a PRECONDITION of arming and is tracked as the fork-activation and registry + * work items. Until that lands, a non-default value here is a laboratory setting, not a deployment. + */ +public final class PqAnchorConfig { + + private static final Logger LOG = LoggerFactory.getLogger(PqAnchorConfig.class); + + /** System property naming the activation height H. Env: {@code AERE_PQ_ANCHOR_BLOCK}. */ + public static final String PROPERTY_ANCHOR_BLOCK = "aere.pq.anchorBlock"; + + /** + * System property naming the staged threshold schedule, as {@code block:minSeals} pairs separated + * by commas, e.g. {@code 12000000:0,12007200:1,12014400:3,12021600:5}. Env: {@code + * AERE_PQ_ANCHOR_MINSEALS}. + */ + public static final String PROPERTY_MIN_SEALS = "aere.pq.anchorMinSeals"; + + /** System property naming the chain id used in the D and M pre-images. Env: {@code AERE_PQ_CHAINID}. */ + public static final String PROPERTY_CHAIN_ID = "aere.pq.chainId"; + + /** + * EMERGENCY: an upper bound on K. Lowers the effective threshold, never raises it. This is the + * property behind the command-line option {@code --Xaere-pq-anchor-min-seals-max}. Env: {@code + * AERE_PQ_ANCHOR_MIN_SEALS_MAX}. + */ + public static final String PROPERTY_MIN_SEALS_CEILING = "aere.pq.anchor.minSealsCeiling"; + + /** + * EMERGENCY: switch both anchor rules off on this node. This is the property behind the + * command-line option {@code --Xaere-pq-anchor-disarm}. Env: {@code AERE_PQ_ANCHOR_DISABLE}. + */ + public static final String PROPERTY_DISABLE = "aere.pq.anchor.disable"; + + /** + * COST: the largest number of seals a proposer of THIS node writes into a certificate. K is a + * FLOOR, not a cap: a proposer includes every eligible seal it happened to hear in time, so at N=7 + * a K=3 chain carries four, five, six or seven seals in practice, not three. + * + *

MEASURED 2026-08-07, and this is why the property exists. A Falcon-512 seal is 666 bytes. The + * seal counts observed on a live seven-node run with the threshold at 4 were: 42 blocks with 4, 36 + * with 5, 5 with 6. The rehearsal's median header of 3838 bytes is {@code (3838-525)/666 = 4.97} + * seals. Per node per year, at ~165248 blocks/day: one seal 40.2 GB, three 120.5 GB, five 200.9 + * GB, seven 281.2 GB. The figure carried in our own documents until that day, 45.2 GB/year, is + * 1.13 seals: it had been computed for a single seal and was wrong by 4.4x. + * + *

Setting this to K therefore removes ~40% of the anchor's disk cost and takes nothing from the + * quorum margin, because the margin is decided by the THRESHOLD a verifier requires, not by how + * many seals a proposer volunteers above it. + * + *

UNSET MEANS TODAY'S BEHAVIOUR: no cap. That is deliberate. A cap is a live-chain behaviour + * change and must be asked for, never arrive as a default. + * + *

FAIL-CLOSED: a cap below the highest K the schedule ever reaches would make every proposer + * assemble a certificate its own fleet rejects, so the loader REFUSES to start. See {@link + * #withMaxSealsCarried(OptionalInt)}. Env: {@code AERE_PQ_ANCHOR_MAX_SEALS}. + */ + public static final String PROPERTY_MAX_SEALS = "aere.pq.anchor.maxSeals"; + + /** Environment variable twin of {@link #PROPERTY_MAX_SEALS}. */ + public static final String ENV_MAX_SEALS = "AERE_PQ_ANCHOR_MAX_SEALS"; + + /** + * COST, and the big lever: carry a certificate every N-th block instead of every block. + * + *

WHY IT IS ALLOWED TO BE SAFE, and this is the whole argument. Block hashes chain: block N+1 + * commits to block N's hash. So an anchor at height A, whose vanityData binds a Falcon certificate + * over A-1, transitively protects EVERYTHING below A. Rewriting any block under A changes A's + * parent hash, which forces A to be reproduced, which needs a Falcon quorum the adversary does not + * have. The only thing an adversary with every classical validator key can still rewrite is the + * TAIL: the blocks produced since the last anchor. The property is therefore + * + *

fork depth <= anchor interval
+ * + * and it is a knob, not an accident. + * + *

MEASURED 2026-08-07, at K=3 capped, ~165248 blocks/day, 666 bytes a seal, per node per year: + * every block 120.5 GB; every 10th 12.1 GB; every 100th 1.2 GB; every 256th 0.5 GB. Against a + * ~523 ms block, an interval of 100 buys a hundredfold saving for a rewritable tail that grows + * from about half a second to about fifty-two seconds. Algorand ships the same shape at 1 in 256. + * + *

UNSET MEANS EVERY BLOCK, which is today's design and the strongest setting. As with the seal + * cap, a weakening never arrives as a default; it has to be asked for. + * + *

ANCHOR HEIGHTS ARE COUNTED FROM H, not from zero: {@code (n - anchorBlock) % interval == 0}. + * H itself is therefore always an anchor height, which matters because H is the one height the + * whole fleet coordinates on. Env: {@code AERE_PQ_ANCHOR_INTERVAL}. + */ + public static final String PROPERTY_ANCHOR_INTERVAL = "aere.pq.anchorInterval"; + + /** Environment variable twin of {@link #PROPERTY_ANCHOR_INTERVAL}. */ + public static final String ENV_ANCHOR_INTERVAL = "AERE_PQ_ANCHOR_INTERVAL"; + + /** + * System property naming the INTERVAL SCHEDULE: "h:interval,h:interval". From each height h the + * certificate is carried every {@code interval} blocks instead of {@link #PROPERTY_ANCHOR_INTERVAL}. + * D-336 (2026-09-04): a hybrid anchor is ~53 KB, so at interval 32 the chain costs ~8.35 GB per + * month per node; a scheduled interval lets the fleet move to 128 at a coordinated height. + */ + public static final String PROPERTY_ANCHOR_INTERVAL_SCHEDULE = "aere.pq.anchorIntervalSchedule"; + + /** Environment variable twin of {@link #PROPERTY_ANCHOR_INTERVAL_SCHEDULE}. */ + public static final String ENV_ANCHOR_INTERVAL_SCHEDULE = "AERE_PQ_ANCHOR_INTERVAL_SCHEDULE"; + + /** + * AERE ANCHOR V2 (2026-09-03): from this height every anchor header carries the SCHEME-TAGGED + * certificate (PqAnchorV2: Falcon-512 plus every extra scheme the scheme schedule names at the + * parent height, K seals per scheme) and vanityData is the v2 digest under "AERE-PQ-ANCHOR-2". + * Below it nothing changes. A v2 certificate is refused below this height and a v1 one from it, + * so the switch is a consensus fork: every node of the fleet must carry the same value, exactly + * like {@link #PROPERTY_ANCHOR_BLOCK}. Absent means never. Env: {@code AERE_PQ_ANCHOR_V2_BLOCK}. + */ + public static final String PROPERTY_ANCHOR_V2_BLOCK = "aere.pq.anchorV2Block"; + + /** Environment variable twin of {@link #PROPERTY_ANCHOR_V2_BLOCK}. */ + public static final String ENV_ANCHOR_V2_BLOCK = "AERE_PQ_ANCHOR_V2_BLOCK"; + + /** Environment variable twin of {@link #PROPERTY_ANCHOR_BLOCK}. */ + public static final String ENV_ANCHOR_BLOCK = "AERE_PQ_ANCHOR_BLOCK"; + + /** Environment variable twin of {@link #PROPERTY_MIN_SEALS}. */ + public static final String ENV_MIN_SEALS = "AERE_PQ_ANCHOR_MINSEALS"; + + /** Environment variable twin of {@link #PROPERTY_CHAIN_ID}. */ + public static final String ENV_CHAIN_ID = "AERE_PQ_CHAINID"; + + /** Environment variable twin of {@link #PROPERTY_MIN_SEALS_CEILING}. */ + public static final String ENV_MIN_SEALS_CEILING = "AERE_PQ_ANCHOR_MIN_SEALS_MAX"; + + /** Environment variable twin of {@link #PROPERTY_DISABLE}. */ + public static final String ENV_DISABLE = "AERE_PQ_ANCHOR_DISABLE"; + + /** + * The stable, greppable code carried by every startup refusal raised while reading this + * configuration, in the shape of the A8 registry refusal {@code AERE-PQC-REG-MISMATCH-01}. + */ + public static final String REFUSAL_CODE = "AERE-PQC-ANCHOR-CONF-01"; + + /** + * D-147: the greppable name of the guard that refuses an armed anchor whose schedule never demands + * a single signature. Named, and not just a message, so that a check can ask whether the guard + * EXISTS rather than whether some prose happens to be present. + */ + public static final String REFUSAL_MIN_SEALS_FLOOR = REFUSAL_CODE + "/minSealsFloor"; + + /** Sentinel meaning "the anchor never activates". */ + public static final long NEVER = Long.MAX_VALUE; + + /** + * Every name this loader reads, property first and environment twin second. Presence of ANY of + * them is what switches the loader from "this node has no anchor configuration" to "this node has + * an anchor configuration and every field of it must be readable". + */ + private static final String[][] ANCHOR_NAMES = { + {PROPERTY_ANCHOR_BLOCK, ENV_ANCHOR_BLOCK}, + {PROPERTY_MIN_SEALS, ENV_MIN_SEALS}, + {PROPERTY_CHAIN_ID, ENV_CHAIN_ID}, + {PROPERTY_MIN_SEALS_CEILING, ENV_MIN_SEALS_CEILING}, + {PROPERTY_MAX_SEALS, ENV_MAX_SEALS}, + {PROPERTY_ANCHOR_INTERVAL, ENV_ANCHOR_INTERVAL}, + {PROPERTY_ANCHOR_INTERVAL_SCHEDULE, ENV_ANCHOR_INTERVAL_SCHEDULE}, + {PROPERTY_ANCHOR_V2_BLOCK, ENV_ANCHOR_V2_BLOCK}, + {PROPERTY_DISABLE, ENV_DISABLE} + }; + + private static final String SOURCE_PROPERTY = "system property (BESU_OPTS)"; + + private final long chainId; + private final long anchorBlock; + private final NavigableMap minSealsSchedule; + private final OptionalInt minSealsCeiling; + private final boolean disabled; + private final OptionalInt maxSealsCarried; + private final OptionalInt anchorInterval; + private final long anchorV2Block; + private final NavigableMap anchorIntervalSchedule; + + /** + * Build an explicit configuration. + * + * @param chainId the chain id used in the D and M pre-images + * @param anchorBlock the activation height H, or {@link #NEVER}; must be at least 1 when set, + * because block H needs a parent + * @param minSealsSchedule the staged threshold, keyed on the height each step takes effect at; may + * be empty, in which case K is 0 everywhere + * @param minSealsCeiling an emergency upper bound on K, or empty + * @param disabled true to switch the anchor rules off entirely + */ + public PqAnchorConfig( + final long chainId, + final long anchorBlock, + final Map minSealsSchedule, + final OptionalInt minSealsCeiling, + final boolean disabled) { + this(chainId, anchorBlock, minSealsSchedule, minSealsCeiling, disabled, OptionalInt.empty()); + } + + /** + * The full constructor, with the proposer-side seal cap. + * + * @param chainId the chain id used in the D and M pre-images + * @param anchorBlock the activation height H, or {@link #NEVER} + * @param minSealsSchedule the staged threshold, keyed on the height each step takes effect at + * @param minSealsCeiling an emergency upper bound on K, or empty + * @param disabled true to switch the anchor rules off entirely + * @param maxSealsCarried the largest certificate this node's proposer writes, or empty for no cap + */ + public PqAnchorConfig( + final long chainId, + final long anchorBlock, + final Map minSealsSchedule, + final OptionalInt minSealsCeiling, + final boolean disabled, + final OptionalInt maxSealsCarried) { + this(chainId, anchorBlock, minSealsSchedule, minSealsCeiling, disabled, maxSealsCarried, OptionalInt.empty()); + } + + /** + * The full constructor, with both cost controls. + * + * @param chainId the chain id used in the D and M pre-images + * @param anchorBlock the activation height H, or {@link #NEVER} + * @param minSealsSchedule the staged threshold, keyed on the height each step takes effect at + * @param minSealsCeiling an emergency upper bound on K, or empty + * @param disabled true to switch the anchor rules off entirely + * @param maxSealsCarried the largest certificate this node's proposer writes, or empty for no cap + * @param anchorInterval carry a certificate every N-th block from H, or empty for every block + */ + public PqAnchorConfig( + final long chainId, + final long anchorBlock, + final Map minSealsSchedule, + final OptionalInt minSealsCeiling, + final boolean disabled, + final OptionalInt maxSealsCarried, + final OptionalInt anchorInterval) { + this(chainId, anchorBlock, minSealsSchedule, minSealsCeiling, disabled, maxSealsCarried, + anchorInterval, NEVER); + } + + /** + * Full constructor. + * + * @param anchorV2Block the height from which anchor headers carry the scheme-tagged v2 + * certificate and the v2 digest, or {@link #NEVER} + */ + public PqAnchorConfig( + final long chainId, + final long anchorBlock, + final Map minSealsSchedule, + final OptionalInt minSealsCeiling, + final boolean disabled, + final OptionalInt maxSealsCarried, + final OptionalInt anchorInterval, + final long anchorV2Block) { + this(chainId, anchorBlock, minSealsSchedule, minSealsCeiling, disabled, maxSealsCarried, + anchorInterval, anchorV2Block, new TreeMap<>()); + } + + /** + * Full constructor with the interval schedule (D-336). + * + * @param anchorIntervalSchedule heights from which the interval changes; every interval must be a + * positive multiple of the base interval and every height must sit on the NEW grid counted + * from H, so that the anchors of the new regime are a subset of the old ones and the change + * never invents an anchor height the old regime did not have + */ + private PqAnchorConfig( + final long chainId, + final long anchorBlock, + final Map minSealsSchedule, + final OptionalInt minSealsCeiling, + final boolean disabled, + final OptionalInt maxSealsCarried, + final OptionalInt anchorInterval, + final long anchorV2Block, + final NavigableMap anchorIntervalSchedule) { + this.anchorInterval = anchorInterval; + this.anchorV2Block = anchorV2Block; + this.anchorIntervalSchedule = new TreeMap<>(anchorIntervalSchedule); + if (!this.anchorIntervalSchedule.isEmpty()) { + if (anchorInterval.isEmpty()) { + throw new IllegalArgumentException( + "AERE PQ ANCHOR: " + PROPERTY_ANCHOR_INTERVAL_SCHEDULE + " is set but " + + PROPERTY_ANCHOR_INTERVAL + " is not: a schedule changes an interval, it cannot" + + " create one"); + } + if (anchorBlock == NEVER) { + throw new IllegalArgumentException( + "AERE PQ ANCHOR: " + PROPERTY_ANCHOR_INTERVAL_SCHEDULE + + " is set but no anchor activation height is"); + } + final int base = anchorInterval.getAsInt(); + for (final Map.Entry e : this.anchorIntervalSchedule.entrySet()) { + final long h = e.getKey(); + final int iv = e.getValue(); + if (iv < 1 || iv % base != 0) { + throw new IllegalArgumentException( + "AERE PQ ANCHOR: " + PROPERTY_ANCHOR_INTERVAL_SCHEDULE + " names interval " + iv + + " at " + h + ", which is not a positive multiple of the base interval " + base + + ": the anchors of the new regime must be a subset of the old ones"); + } + if (h < anchorBlock || (h - anchorBlock) % iv != 0) { + throw new IllegalArgumentException( + "AERE PQ ANCHOR: " + PROPERTY_ANCHOR_INTERVAL_SCHEDULE + " changes the interval to " + + iv + " at " + h + ", which is not on the new grid counted from H=" + anchorBlock + + " ((h - H) % " + iv + " must be 0): the first anchor of the new regime is the" + + " activation height itself, so every node can check it by hand"); + } + } + } + if (anchorV2Block != NEVER) { + if (anchorV2Block < 1) { + throw new IllegalArgumentException( + "AERE PQ ANCHOR: " + PROPERTY_ANCHOR_V2_BLOCK + " must be at least 1, got " + anchorV2Block); + } + if (anchorBlock == NEVER) { + throw new IllegalArgumentException( + "AERE PQ ANCHOR: " + PROPERTY_ANCHOR_V2_BLOCK + "=" + anchorV2Block + + " is set but no anchor activation height is: a v2 certificate is a form of the" + + " anchor certificate, it cannot exist where no anchor does"); + } + if (anchorV2Block < anchorBlock) { + throw new IllegalArgumentException( + "AERE PQ ANCHOR: " + PROPERTY_ANCHOR_V2_BLOCK + "=" + anchorV2Block + + " is below the anchor activation height " + anchorBlock + + "; the v2 form cannot take effect before the anchor rules do"); + } + } + if (anchorInterval.isPresent() && anchorInterval.getAsInt() < 1) { + throw new IllegalArgumentException( + "AERE PQ ANCHOR: " + + PROPERTY_ANCHOR_INTERVAL + + " must be at least 1, got " + + anchorInterval.getAsInt() + + "; 1 means every block, which is the strongest setting, and there is nothing below it"); + } + this.maxSealsCarried = maxSealsCarried; + if (maxSealsCarried.isPresent() && maxSealsCarried.getAsInt() < 1) { + throw new IllegalArgumentException( + "AERE PQ ANCHOR: " + + PROPERTY_MAX_SEALS + + " must be at least 1, got " + + maxSealsCarried.getAsInt() + + "; a cap of 0 would make every proposer write an empty certificate"); + } + if (anchorBlock != NEVER && anchorBlock < 1) { + throw new IllegalArgumentException( + "AERE PQ ANCHOR: pqAnchorBlock must be at least 1 (block H needs a parent), got " + + anchorBlock); + } + final NavigableMap schedule = new TreeMap<>(); + for (final Map.Entry step : minSealsSchedule.entrySet()) { + final Long at = step.getKey(); + final Integer k = step.getValue(); + if (at == null || k == null) { + throw new IllegalArgumentException("AERE PQ ANCHOR: null entry in pqAnchorMinSeals"); + } + if (k < 0) { + throw new IllegalArgumentException( + "AERE PQ ANCHOR: pqAnchorMinSeals step at " + at + " has a negative threshold " + k); + } + if (anchorBlock != NEVER && at < anchorBlock) { + throw new IllegalArgumentException( + "AERE PQ ANCHOR: pqAnchorMinSeals step at " + + at + + " is below pqAnchorBlock " + + anchorBlock + + "; a threshold cannot take effect before the rules do"); + } + schedule.put(at, k); + } + if (minSealsCeiling.isPresent() && minSealsCeiling.getAsInt() < 0) { + throw new IllegalArgumentException( + "AERE PQ ANCHOR: emergency min-seals ceiling must not be negative"); + } + this.chainId = chainId; + this.anchorBlock = anchorBlock; + this.minSealsSchedule = Collections.unmodifiableNavigableMap(schedule); + this.minSealsCeiling = minSealsCeiling; + this.disabled = disabled; + } + + /** + * The configuration that behaves exactly like today: the anchor never activates, so both new rules + * are inert at every height. + * + * @param chainId the chain id (unused while inactive, carried for diagnostics) + * @return an inactive configuration + */ + public static PqAnchorConfig never(final long chainId) { + return new PqAnchorConfig(chainId, NEVER, Collections.emptyMap(), OptionalInt.empty(), false); + } + + // ----------------------------------------------------------------------------------------------- + // Reading the names. ONE seam, used by both controls of the proof: a recording reader shows that + // the unconfigured path touches nothing, and a reader whose presence probe always answers "absent" + // is the negative control that makes every refusal below disappear. + // ----------------------------------------------------------------------------------------------- + + /** How this class reaches system properties and environment variables. */ + interface NameReader { + + /** + * The raw system property, or null when the name is not set. + * + * @param name the system property name + * @return the value as set, possibly empty, or null when absent + */ + String property(String name); + + /** + * The raw environment variable, or null when the name is not set. + * + * @param name the environment variable name + * @return the value as set, possibly empty, or null when absent + */ + String environment(String name); + } + + private static final NameReader REAL_NAMES = + new NameReader() { + @Override + public String property(final String name) { + return System.getProperty(name); + } + + @Override + public String environment(final String name) { + return System.getenv(name); + } + }; + + private static volatile NameReader names = REAL_NAMES; + + /** + * Replace the reader, for the positive and negative controls of the configuration proof. + * + * @param replacement the reader to use, or null to restore the real one + */ + static void useNameReaderForTesting(final NameReader replacement) { + names = replacement == null ? REAL_NAMES : replacement; + } + + /** + * The real reader, so a recording test reader can delegate to it instead of re-implementing it. + * + * @return the reader that reads actual system properties and environment variables + */ + static NameReader realNameReader() { + return REAL_NAMES; + } + + /** + * One name as this loader saw it. + * + * @param property the system property name + * @param environment the environment variable name + * @param present whether the name was set at all, INCLUDING being set to the empty string + * @param source human-readable origin, for the refusal message + * @param raw the trimmed value, possibly empty, or null when absent + */ + private record Read( + String property, String environment, boolean present, String source, String raw) { + + boolean hasValue() { + return present && !raw.isEmpty(); + } + } + + private static Read read(final String property, final String environment) { + final NameReader reader = names; + final String fromProperty = reader.property(property); + if (fromProperty != null) { + return new Read(property, environment, true, SOURCE_PROPERTY, fromProperty.trim()); + } + final String fromEnvironment = reader.environment(environment); + if (fromEnvironment != null) { + return new Read( + property, + environment, + true, + "environment variable " + environment, + fromEnvironment.trim()); + } + return new Read(property, environment, false, null, null); + } + + private static boolean anyAnchorNamePresent() { + for (final String[] pair : ANCHOR_NAMES) { + if (read(pair[0], pair[1]).present()) { + return true; + } + } + return false; + } + + // ----------------------------------------------------------------------------------------------- + // The loader. + // ----------------------------------------------------------------------------------------------- + + /** + * Read the configuration from system properties, falling back to environment variables, and apply + * the emergency overrides. + * + *

A node with NONE of the {@code aere.pq.*} names set gets {@link #never(long)} at the first + * statement, i.e. exactly today's behaviour with no decode, no allocation and no log. A node with + * at least one of them set is read strictly and REFUSES TO START on any fault, except through the + * {@value #PROPERTY_DISABLE} brake. See the class javadoc for why the gate is on presence. + * + * @return the configuration this node will run with + * @throws FalconSealSupport.ActivationConfigException when an anchor name is present and the + * configuration cannot be read exactly as written + */ + public static PqAnchorConfig fromSystemConfiguration() { + if (!anyAnchorNamePresent()) { + return never(0L); + } + + final Read block = read(PROPERTY_ANCHOR_BLOCK, ENV_ANCHOR_BLOCK); + final Read seals = read(PROPERTY_MIN_SEALS, ENV_MIN_SEALS); + final Read chain = read(PROPERTY_CHAIN_ID, ENV_CHAIN_ID); + final Read ceiling = read(PROPERTY_MIN_SEALS_CEILING, ENV_MIN_SEALS_CEILING); + final Read disable = read(PROPERTY_DISABLE, ENV_DISABLE); + + final boolean disabled = readDisable(disable); + + if (disabled) { + // THE BRAKE IS ABSOLUTE. The whole reason this control is local to the node is that it has to + // work when the chain is already stopped; a brake that can refuse to engage is not a brake. + // The rest is still parsed, because a configuration that reads cleanly keeps + // anchorConfigured() true and therefore keeps the per-block disarm announcement shouting. + try { + final PqAnchorConfig braked = strict(block, seals, chain, ceiling, true); + if (braked.anchorConfigured()) { + LOG.warn( + "AERE PQ ANCHOR: DISABLED by emergency override {} even though {}={} is configured. " + + "Both anchor rules are inert on this node.", + PROPERTY_DISABLE, + PROPERTY_ANCHOR_BLOCK, + braked.anchorBlock); + } + return braked; + } catch (final RuntimeException e) { + LOG.error( + "AERE PQ ANCHOR: the emergency disarm {} is in force, so this node STARTS, but the rest " + + "of the anchor configuration cannot be read and is therefore NOT carried: {}. The " + + "node runs fully inert and the per-block disarm announcement is LOST, because " + + "there is no readable activation height to announce. Fix the configuration before " + + "removing the disarm.", + PROPERTY_DISABLE, + e.getMessage()); + return never(0L); + } + } + + final PqAnchorConfig bare = strict(block, seals, chain, ceiling, false); + final PqAnchorConfig config = + bare.withMaxSealsCarried(readMaxSeals()) + .withAnchorInterval(readAnchorInterval()) + .withAnchorIntervalSchedule(readAnchorIntervalSchedule()) + .withAnchorV2Block(readAnchorV2Block()); + requireMinSealsFloorOrRefuse(config); + if (config.anchorInterval().isPresent() && config.everActive()) { + final int iv = config.anchorInterval().getAsInt(); + LOG.warn( + "AERE PQ ANCHOR: certificate carried every {} block(s) from H={}, not every block. The " + + "hash chain makes each anchor protect everything BELOW it, so what stays rewritable " + + "by an adversary holding every classical validator key is the TAIL since the last " + + "anchor: fork depth <= {} blocks. This is a DELIBERATE weakening bought for disk: " + + "at K=3 capped it is about {} GB per node per year instead of about 120.", + iv, + config.anchorBlock, + iv, + String.format("%.1f", 120.5 / iv)); + } + if (!config.anchorIntervalSchedule().isEmpty() && config.everActive()) { + LOG.warn( + "AERE PQ ANCHOR: interval SCHEDULE in force - {} (height:interval). From each height the" + + " certificate is carried every that-many blocks; the anchors of every later regime are" + + " a subset of the earlier ones (D-336, disk).", + config.anchorIntervalSchedule()); + } + if (config.maxSealsCarried().isPresent() && config.everActive()) { + LOG.warn( + "AERE PQ ANCHOR: proposer-side seal CAP in force - this node writes at most {} seal(s) " + + "per certificate, against a highest scheduled threshold of K={}. This lowers the " + + "header cost and takes NOTHING from the quorum margin, which is decided by the " + + "threshold a verifier demands, not by how many seals a proposer volunteers above " + + "it. Measured 2026-08-07: uncapped, a K=3 chain at N=7 carries about five seals, " + + "which is 200.9 GB per node per year; capped at K it is 120.5 GB.", + config.maxSealsCarried().getAsInt(), + config.highestEffectiveMinSeals()); + } + if (config.everActive()) { + LOG.warn( + "AERE PQ ANCHOR: ARMED from LOCAL configuration - pqAnchorBlock={}, chainId={}, " + + "minSeals schedule={}, emergency ceiling={}. These values are NOT bound to genesis " + + "and NOT bound to consensus: two nodes with different values disagree about which " + + "headers are valid. Bind them to genesis before any deployment.", + config.anchorBlock, + config.chainId, + config.minSealsSchedule, + config.minSealsCeiling.isPresent() ? config.minSealsCeiling.getAsInt() : "none"); + } + return config; + } + + /** + * Every field read exactly as written, or a refusal. Nothing here falls back to a default: a + * default that silently replaces a value the operator typed is the defect this method exists to + * remove. + */ + private static PqAnchorConfig strict( + final Read block, + final Read seals, + final Read chain, + final Read ceiling, + final boolean disabled) { + + final OptionalInt ceilingValue = + ceiling.present() + ? OptionalInt.of( + readCeiling(ceiling)) + : OptionalInt.empty(); + + final long chainId = chain.present() ? readChainId(chain) : 0L; + + if (!block.present()) { + if (seals.present()) { + throw refuse( + seals, + FalconSealSupport.ActivationConfigException.Kind.UNSAFE, + "a threshold schedule is configured but " + + PROPERTY_ANCHOR_BLOCK + + " is not set anywhere, so the schedule can never apply and the anchor is dead " + + "while looking configured", + "either set " + + PROPERTY_ANCHOR_BLOCK + + " as well, or remove " + + PROPERTY_MIN_SEALS + + " entirely"); + } + return new PqAnchorConfig( + chainId, NEVER, Collections.emptyMap(), ceilingValue, disabled); + } + + final long anchorBlock = readAnchorBlock(block); + + if (!chain.present()) { + // The Holesky field, by name. There, depositContractAddress was absent on three clients, the + // default zero took its place, every node started, and the chain lost finality for two weeks + // at the height where the value finally mattered. An absent field must not be indistinguishable + // from a zero field, so this one is required and explicit, exactly as go-ethereum made the + // deposit contract address required per network afterwards. + throw refuse( + chain, + FalconSealSupport.ActivationConfigException.Kind.UNSAFE, + PROPERTY_ANCHOR_BLOCK + + "=" + + anchorBlock + + " is set but " + + PROPERTY_CHAIN_ID + + " is not set anywhere; the silent default 0 would enter the D and M pre-images and " + + "would remove domain separation without showing anywhere", + "an explicit chain id, for example " + PROPERTY_CHAIN_ID + "=2800"); + } + if (chainId < 1) { + throw refuse( + chain, + FalconSealSupport.ActivationConfigException.Kind.UNSAFE, + "chain id " + chainId + "; zero and negative values separate no domain at all", + "an explicit chain id >= 1, for example " + PROPERTY_CHAIN_ID + "=2800"); + } + + if (!seals.hasValue()) { + throw refuse( + seals, + FalconSealSupport.ActivationConfigException.Kind.UNSAFE, + PROPERTY_ANCHOR_BLOCK + + "=" + + anchorBlock + + " is set but the threshold schedule is missing or empty, so K would be 0 at every " + + "height and an ARMED node would accept empty certificates", + scheduleExpectation(anchorBlock)); + } + + final NavigableMap schedule = readSchedule(seals, anchorBlock); + if (schedule.floorEntry(anchorBlock) == null) { + throw refuse( + seals, + FalconSealSupport.ActivationConfigException.Kind.UNSAFE, + "the schedule has no step at the activation height " + + anchorBlock + + ", so K stays 0 from activation until the first step, and an ARMED node with K=0 " + + "accepts an empty certificate", + scheduleExpectation(anchorBlock)); + } + + try { + return new PqAnchorConfig(chainId, anchorBlock, schedule, ceilingValue, disabled); + } catch (final IllegalArgumentException e) { + throw refuse( + block, + FalconSealSupport.ActivationConfigException.Kind.UNSAFE, + "the configuration reads but is not valid: " + e.getMessage(), + scheduleExpectation(anchorBlock)); + } + } + + private static String scheduleExpectation(final long anchorBlock) { + return "block:threshold,block:threshold - every block >= " + + PROPERTY_ANCHOR_BLOCK + + "=" + + anchorBlock + + ", whole threshold >= 0, and a step exactly at " + + anchorBlock; + } + + /** + * Read the proposer-side seal cap. Absent means no cap, which is exactly today's behaviour; an + * unreadable value is a refusal, never a silent fallback to "no cap", because a cap that was asked + * for and quietly dropped costs disk nobody is watching. + */ + private static OptionalInt readMaxSeals() { + final Read cap = read(PROPERTY_MAX_SEALS, ENV_MAX_SEALS); + if (!cap.hasValue()) { + return OptionalInt.empty(); + } + try { + return OptionalInt.of(Integer.parseInt(cap.raw())); + } catch (final NumberFormatException e) { + throw new IllegalArgumentException( + REFUSAL_CODE + + ": " + + PROPERTY_MAX_SEALS + + " is set to '" + + cap.raw() + + "' from " + + cap.source() + + ", which is not a whole number. It is NOT ignored: a cap asked for and silently " + + "dropped costs disk nobody is watching."); + } + } + + /** + * D-147, THE MIN-SEALS FLOOR. A schedule whose effective K is ZERO at every height leaves the + * anchor ARMED as a structure and completely toothless, for ever. + * + *

WHAT THE CODE ALREADY GUARDED, and why that was not enough. The loader already refuses a + * MISSING schedule, in its own words: {@code "the threshold schedule is missing or empty, so K + * would be 0 at every height and an ARMED node would accept empty certificates"}. The author knew + * exactly what the danger was. But the guard only fired on an ABSENT schedule. A schedule that is + * PRESENT and of the shape {@code :0} reaches the same end state and went through unseen. + * + *

WHY THIS IS NOT THEORETICAL: it is the very shape our activation plan recommends, one that + * STARTS at K=0 as a warm-up window and rises to 3 later. If the second half of the line is lost, + * to a truncated environment variable or a misplaced quote, what is left is {@code :0}. The + * nodes start, every tool comes out green because each of them measures what was ASKED FOR and + * the ask is valid, and the threshold stays zero for ever. Not even ancora-prag-efectiv.sh + * catches this: it compares what came out against what was asked for, and here both are zero. + * + *

The warm-up window stays perfectly legal: this looks at the HIGHEST K in the whole schedule, + * after the emergency ceiling, so {@code H:0,H+165000:3} passes and {@code H:0} on its own does + * not. + * + *

THE LIMIT, written down because it is real: the check sits on the LOADING path, not in the + * constructor. A test that builds an all-zero-schedule object directly is not stopped. The real + * danger is an operator configuration in BESU_OPTS, and that is where the guard is placed. + */ + private static void requireMinSealsFloorOrRefuse(final PqAnchorConfig config) { + if (!config.everActive() || config.disabled) { + return; + } + if (config.highestEffectiveMinSeals() >= 1) { + return; + } + throw new IllegalArgumentException( + REFUSAL_MIN_SEALS_FLOOR + + ": " + + PROPERTY_ANCHOR_BLOCK + + "=" + + config.anchorBlock + + " is armed, but the highest threshold in the WHOLE schedule " + + PROPERTY_MIN_SEALS + + "=" + + config.minSealsSchedule + + (config.minSealsCeiling.isPresent() + ? " (after the emergency ceiling " + config.minSealsCeiling.getAsInt() + ")" + : "") + + " is ZERO. A node started like this has the anchor armed and NO signature requirement" + + " at all, for ever, and every tool comes out green because it measures what was asked" + + " for. A warm-up window with K=0 is legal, but the schedule has to rise somewhere to" + + " at least 1: for example " + + config.anchorBlock + + ":0," + + (config.anchorBlock + 165000L) + + ":3"); + } + + /** + * Read the anchor interval. Absent means every block, the strongest setting; an unreadable value + * is a refusal, never a silent fallback, because a weakening asked for and quietly dropped and a + * weakening quietly APPLIED are both states an operator must never be left guessing between. + */ + /** + * Read the v2 activation height. Absent means never; an unreadable value is a refusal, because + * this height is a consensus fork and a node guessing it differently from its peers rejects + * every anchor header its peers accept, or the other way round. + */ + private static long readAnchorV2Block() { + final Read v2 = read(PROPERTY_ANCHOR_V2_BLOCK, ENV_ANCHOR_V2_BLOCK); + if (!v2.hasValue()) { + return NEVER; + } + try { + return Long.parseLong(v2.raw().trim()); + } catch (final NumberFormatException e) { + throw new IllegalArgumentException( + REFUSAL_CODE + + ": " + + PROPERTY_ANCHOR_V2_BLOCK + + " is set to '" + + v2.raw() + + "' from " + + v2.source() + + ", which is not a whole number. It is NOT ignored: this height decides which form of" + + " the anchor certificate is valid, and a node guessing it differently from its peers" + + " parts from the chain at the first anchor."); + } + } + + private static NavigableMap readAnchorIntervalSchedule() { + final Read sc = read(PROPERTY_ANCHOR_INTERVAL_SCHEDULE, ENV_ANCHOR_INTERVAL_SCHEDULE); + final NavigableMap out = new TreeMap<>(); + if (!sc.hasValue()) { + return out; + } + for (final String part : Splitter.on(',').split(sc.raw())) { + final String p = part.trim(); + if (p.isEmpty()) { + continue; + } + final int colon = p.indexOf(':'); + try { + if (colon < 1) { + throw new NumberFormatException(p); + } + out.put(Long.parseLong(p.substring(0, colon).trim()), Integer.parseInt(p.substring(colon + 1).trim())); + } catch (final NumberFormatException e) { + throw new IllegalArgumentException( + REFUSAL_CODE + + ": " + + PROPERTY_ANCHOR_INTERVAL_SCHEDULE + + " is set to '" + + sc.raw() + + "' from " + + sc.source() + + ", which is not a list of height:interval. It is NOT ignored: two nodes that read" + + " it differently disagree about which headers carry a certificate."); + } + } + return out; + } + + private static OptionalInt readAnchorInterval() { + final Read iv = read(PROPERTY_ANCHOR_INTERVAL, ENV_ANCHOR_INTERVAL); + if (!iv.hasValue()) { + return OptionalInt.empty(); + } + try { + return OptionalInt.of(Integer.parseInt(iv.raw())); + } catch (final NumberFormatException e) { + throw new IllegalArgumentException( + REFUSAL_CODE + + ": " + + PROPERTY_ANCHOR_INTERVAL + + " is set to '" + + iv.raw() + + "' from " + + iv.source() + + ", which is not a whole number. It is NOT ignored: this value decides how much of " + + "the chain tip stays rewritable, and a node guessing it differently from its peers " + + "disagrees about which headers carry a certificate at all."); + } + } + + private static boolean readDisable(final Read disable) { + if (!disable.present()) { + return false; + } + final String value = disable.raw().toLowerCase(Locale.ROOT); + switch (value) { + case "true": + case "1": + case "yes": + case "on": + return true; + case "false": + case "0": + case "no": + case "off": + return false; + default: + // Boolean.parseBoolean turns "1", "yes" and "on" into FALSE without a word. That is a brake + // pedal that reports nothing when it does not engage, on the one control an operator reaches + // for while the chain is down. + throw refuse( + disable, + FalconSealSupport.ActivationConfigException.Kind.SYNTAX, + "the value cannot be read as true or false", + "true, false, 1, 0, yes, no, on or off"); + } + } + + private static long readAnchorBlock(final Read block) { + if (!block.hasValue()) { + throw refuse( + block, + FalconSealSupport.ActivationConfigException.Kind.SYNTAX, + "the name is set but the value is empty; that is what an unexpanded shell variable in " + + "BESU_OPTS looks like, and until today this started the node with the anchor fully " + + "inert, without a single line in the log", + "the activation height H as a decimal number, for example 10141734"); + } + final long value = readLong(block); + if (value < 1) { + throw refuse( + block, + FalconSealSupport.ActivationConfigException.Kind.UNSAFE, + "activation height " + value + " leaves no room for a parent", + "an activation height H >= 1, and in practice a height ahead of the chain head"); + } + return value; + } + + private static long readChainId(final Read chain) { + if (!chain.hasValue()) { + throw refuse( + chain, + FalconSealSupport.ActivationConfigException.Kind.SYNTAX, + "the name is set but the value is empty", + "a chain id as a decimal number, for example 2800"); + } + return readLong(chain); + } + + private static int readCeiling(final Read ceiling) { + if (!ceiling.hasValue()) { + throw refuse( + ceiling, + FalconSealSupport.ActivationConfigException.Kind.SYNTAX, + "the name is set but the value is empty", + "a whole ceiling >= 0; 0 disarms the threshold completely"); + } + final int value; + try { + value = Integer.parseInt(ceiling.raw()); + } catch (final NumberFormatException e) { + // Until today this was IGNORED while AerePqEmergencyOptions announced "SEAL THRESHOLD CAPPED" + // anyway, so the operator read the banner as proof that the brake had engaged when it had not. + throw refuse( + ceiling, + FalconSealSupport.ActivationConfigException.Kind.SYNTAX, + "the value cannot be read as a whole number, and an emergency ceiling silently ignored " + + "is worse than no ceiling at all: the startup banner announces it as being in " + + "force anyway", + "a whole ceiling >= 0; 0 disarms the threshold completely"); + } + if (value < 0) { + throw refuse( + ceiling, + FalconSealSupport.ActivationConfigException.Kind.UNSAFE, + "negative ceiling; until today this silently disarmed the WHOLE anchor, while the banner " + + "announced no more than a capped threshold", + "a whole ceiling >= 0; 0 disarms the threshold completely"); + } + return value; + } + + private static long readLong(final Read where) { + try { + return Long.parseLong(where.raw()); + } catch (final NumberFormatException e) { + throw refuse( + where, + FalconSealSupport.ActivationConfigException.Kind.SYNTAX, + "the value cannot be read as a decimal number", + "a decimal number, no separators, no 0x prefix and no suffixes"); + } + } + + private static NavigableMap readSchedule( + final Read seals, final long anchorBlock) { + final NavigableMap schedule = new TreeMap<>(); + for (final String step : Splitter.on(',').split(seals.raw())) { + final String trimmed = step.trim(); + if (trimmed.isEmpty()) { + throw refuse( + seals, + FalconSealSupport.ActivationConfigException.Kind.SYNTAX, + "an empty step, that is one comma too many or two commas side by side", + scheduleExpectation(anchorBlock)); + } + final int colon = trimmed.indexOf(':'); + if (colon < 0) { + throw refuse( + seals, + FalconSealSupport.ActivationConfigException.Kind.SYNTAX, + "step \"" + trimmed + "\" has no ':'", + scheduleExpectation(anchorBlock)); + } + if (colon == 0) { + throw refuse( + seals, + FalconSealSupport.ActivationConfigException.Kind.SYNTAX, + "step \"" + trimmed + "\" has no block before the ':'", + scheduleExpectation(anchorBlock)); + } + if (colon == trimmed.length() - 1) { + throw refuse( + seals, + FalconSealSupport.ActivationConfigException.Kind.SYNTAX, + "step \"" + trimmed + "\" has no threshold after the ':'", + scheduleExpectation(anchorBlock)); + } + final long at; + final int k; + try { + at = Long.parseLong(trimmed.substring(0, colon).trim()); + k = Integer.parseInt(trimmed.substring(colon + 1).trim()); + } catch (final NumberFormatException e) { + throw refuse( + seals, + FalconSealSupport.ActivationConfigException.Kind.SYNTAX, + "step \"" + trimmed + "\" carries a number that cannot be read", + scheduleExpectation(anchorBlock)); + } + if (k < 0) { + throw refuse( + seals, + FalconSealSupport.ActivationConfigException.Kind.UNSAFE, + "step \"" + trimmed + "\" asks for a negative threshold", + scheduleExpectation(anchorBlock)); + } + if (at < anchorBlock) { + throw refuse( + seals, + FalconSealSupport.ActivationConfigException.Kind.UNSAFE, + "step \"" + + trimmed + + "\" is below the activation height " + + anchorBlock + + ", so a threshold would take effect before the rules do", + scheduleExpectation(anchorBlock)); + } + final Integer previous = schedule.put(at, k); + if (previous != null && previous != k) { + throw refuse( + seals, + FalconSealSupport.ActivationConfigException.Kind.UNSAFE, + "height " + + at + + " appears twice with different thresholds, " + + previous + + " and " + + k + + ", so the threshold at that height depends on the order within the string", + scheduleExpectation(anchorBlock)); + } + } + return schedule; + } + + /** + * The one shape a refusal takes: the field, where it was read from, what was actually read, what + * is wrong with it, what was expected, and the two operating instructions that matter more than + * any of it - repair this node, and never restart the fleet in parallel. + */ + private static FalconSealSupport.ActivationConfigException refuse( + final Read where, + final FalconSealSupport.ActivationConfigException.Kind kind, + final String problem, + final String expected) { + final StringBuilder message = new StringBuilder(768); + message + .append("AERE PQ ANCHOR: REFUSING TO START\n") + .append(" FIELD ") + .append(where.property()) + .append('\n') + .append(" SOURCE ") + .append(where.present() ? where.source() : "not set anywhere") + .append('\n') + .append(" READ ") + .append(where.present() ? "\"" + where.raw() + "\"" : "(nothing)") + .append('\n') + .append(" PROBLEM ") + .append(problem) + .append('\n') + .append(" EXPECTED ") + .append(expected) + .append('\n') + .append(" FIX correct BESU_OPTS on THIS node and restart ONLY this node\n") + .append(" WARNING if the same value is on every node: restart one at a time,\n") + .append(" never in parallel. The chain stops as soon as more than f\n") + .append(" validators are down at once, whatever the set size is today.\n") + .append(" EMERGENCY ") + .append(PROPERTY_DISABLE) + .append("=true starts the node with the anchor off and shouts at every block"); + return new FalconSealSupport.ActivationConfigException(kind, REFUSAL_CODE, message.toString()); + } + + /** + * Whether the anchor rules apply to a block at this height. + * + * @param blockNumber the height being validated + * @return true iff the anchor is configured, not emergency-disabled, and the height is at or above + * H + */ + public boolean activeAt(final long blockNumber) { + return !disabled && anchorBlock != NEVER && blockNumber >= anchorBlock; + } + + /** + * Whether the anchor can ever apply on this node. + * + * @return true iff configured and not emergency-disabled + */ + public boolean everActive() { + return !disabled && anchorBlock != NEVER; + } + + /** + * The minimum number of Falcon seals a certificate must carry at this height, after the emergency + * ceiling is applied. + * + * @param blockNumber the height being validated + * @return the effective threshold K, never negative + */ + public int minSealsAt(final long blockNumber) { + final int scheduled = scheduledMinSealsAt(blockNumber); + return minSealsCeiling.isPresent() ? Math.min(scheduled, minSealsCeiling.getAsInt()) : scheduled; + } + + /** + * The threshold the SCHEDULE asks for at this height, before the emergency ceiling is applied. + * + *

AERE OPTIUNI-URGENTA (2026-08-02). This exists so the per-block emergency announcement can + * state both numbers from the SAME object the rules read, rather than re-deriving one of them from + * configuration a second time. A message that re-computes its own version of what the enforcement + * did can drift away from it, and a shout that says something other than what is actually in force + * is worse than no shout. + * + * @param blockNumber the height being validated + * @return the scheduled threshold K, never negative, ignoring any emergency ceiling + */ + public int scheduledMinSealsAt(final long blockNumber) { + final Map.Entry step = minSealsSchedule.floorEntry(blockNumber); + return step == null ? 0 : step.getValue(); + } + + /** + * Whether the emergency ceiling is ACTUALLY lowering the threshold at this height, as opposed to + * merely being set. + * + *

A ceiling of 5 over a scheduled threshold of 1 changes nothing, and a node announcing "the + * threshold is lowered" in that state would be shouting about a control that is carrying no + * weight. Operators stop reading a log that cries wolf, so the announcement is tied to the ceiling + * having an effect, not to it being present. + * + * @param blockNumber the height being validated + * @return true iff the anchor is active here and the ceiling is strictly below the scheduled + * threshold + */ + public boolean emergencyCeilingLowersAt(final long blockNumber) { + return activeAt(blockNumber) + && minSealsCeiling.isPresent() + && minSealsCeiling.getAsInt() < scheduledMinSealsAt(blockNumber); + } + + /** + * The emergency ceiling on K, if one is configured. + * + * @return the ceiling, or empty + */ + public OptionalInt minSealsCeiling() { + return minSealsCeiling; + } + + /** + * The largest certificate this node's proposer writes, or empty for no cap (today's behaviour). + * + * @return the proposer-side seal cap + */ + public OptionalInt maxSealsCarried() { + return maxSealsCarried; + } + + /** + * The highest EFFECTIVE threshold this configuration ever demands, that is the largest {@code K} + * across every step of the schedule after the emergency ceiling has been applied. This is the one + * number a proposer-side cap must never fall below. + * + * @return the highest effective K, or 0 if the schedule is empty + */ + public int highestEffectiveMinSeals() { + int highest = 0; + for (final Integer k : minSealsSchedule.values()) { + final int effective = minSealsCeiling.isPresent() ? Math.min(k, minSealsCeiling.getAsInt()) : k; + highest = Math.max(highest, effective); + } + return highest; + } + + /** + * Return a copy carrying a proposer-side seal cap, refusing any cap that cannot work. + * + *

THE REFUSAL IS THE POINT. A cap below {@link #highestEffectiveMinSeals()} makes this node's + * proposer assemble certificates its own fleet must reject, at every height from the step that + * raises K past the cap. The chain would not diverge and would not corrupt: it would simply stop + * accepting anything this node proposes, which at N=7 costs a seventh of the proposers and, if the + * same cap is deployed fleet-wide, every one of them. That failure is silent in every existing + * tool, because each of them measures what was ASKED and the ask is internally consistent. So it + * is refused here, at startup, where a refusal is cheap. + * + * @param cap the cap to carry, or empty for none + * @return a copy of this configuration carrying the cap + * @throws IllegalArgumentException if the cap is below the highest effective threshold + */ + public PqAnchorConfig withMaxSealsCarried(final OptionalInt cap) { + if (cap.isPresent()) { + final int highest = highestEffectiveMinSeals(); + if (cap.getAsInt() < highest) { + throw new IllegalArgumentException( + REFUSAL_CODE + + ": " + + PROPERTY_MAX_SEALS + + "=" + + cap.getAsInt() + + " is below the highest threshold this schedule ever demands, K=" + + highest + + ". A proposer capped below K writes certificates its own fleet rejects, and every " + + "existing check stays GREEN while it happens, because each one measures what was " + + "asked. Raise the cap to at least " + + highest + + ", or lower the schedule."); + } + } + // CAREFUL: the interval is carried across too. A copy method that drops a field along the way + // is exactly how a disarm or an interval would vanish silently when the other control is set. + return new PqAnchorConfig( + chainId, anchorBlock, minSealsSchedule, minSealsCeiling, disabled, cap, anchorInterval, + anchorV2Block); + } + + /** + * How often a certificate is carried, counted from H. Empty means every block, which is the + * strongest setting and today's design. + * + * @return the anchor interval + */ + public OptionalInt anchorInterval() { + return anchorInterval; + } + + /** + * The interval in force at a height: the base interval, or the last scheduled change at or below + * the height (D-336). + * + * @param blockNumber the height + * @return the interval in force there; 1 when no interval is configured + */ + public int intervalAt(final long blockNumber) { + if (anchorInterval.isEmpty()) { + return 1; + } + final Map.Entry e = anchorIntervalSchedule.floorEntry(blockNumber); + return e == null ? anchorInterval.getAsInt() : e.getValue(); + } + + /** + * The interval schedule (D-336), possibly empty. + * + * @return an unmodifiable view, height to interval + */ + public NavigableMap anchorIntervalSchedule() { + return java.util.Collections.unmodifiableNavigableMap(anchorIntervalSchedule); + } + + /** + * Return a copy carrying an interval schedule (D-336). Refused unless every entry keeps the new + * anchors a subset of the old ones; see the private constructor. + * + * @param schedule heights to intervals + * @return a copy of this configuration carrying the schedule + */ + public PqAnchorConfig withAnchorIntervalSchedule(final Map schedule) { + return new PqAnchorConfig( + chainId, anchorBlock, minSealsSchedule, minSealsCeiling, disabled, maxSealsCarried, + anchorInterval, anchorV2Block, new TreeMap<>(schedule)); + } + + /** + * Is this a height at which a certificate is carried and demanded? + * + *

Counted FROM H, so H itself is always an anchor height. That matters because H is the single + * height the whole fleet coordinates on, and a scheme whose first anchor lands somewhere else + * would be a scheme nobody can check by hand on activation day. + * + * @param blockNumber the height to test + * @return true if a certificate belongs in this block + */ + public boolean isAnchorHeight(final long blockNumber) { + if (anchorInterval.isEmpty()) { + return true; + } + if (anchorBlock == NEVER || blockNumber < anchorBlock) { + return false; + } + return (blockNumber - anchorBlock) % intervalAt(blockNumber) == 0; + } + + /** + * The single question both anchor rules ask: do I judge this header at all? + * + *

Two conditions, and they are different in kind. {@link #activeAt(long)} is about WHETHER the + * scheme is switched on for this node at this height; {@link #isAnchorHeight(long)} is about + * whether this particular height is one that carries a certificate. Folding them into one method + * is deliberate: two rules asking the same question two different ways is exactly how R1 and R2 + * would drift apart, and a header accepted by one and refused by the other is a chain split. + * + * @param blockNumber the height to test + * @return true if the anchor rules judge this header + */ + public boolean anchorAppliesAt(final long blockNumber) { + return activeAt(blockNumber) && isAnchorHeight(blockNumber); + } + + /** + * The v2 activation height, or {@link #NEVER}. + * + * @return the height from which anchor headers carry the scheme-tagged certificate + */ + public long anchorV2Block() { + return anchorV2Block; + } + + /** + * Whether the header at this height must carry the SCHEME-TAGGED (v2) certificate and the v2 + * digest. True only at an anchor height ({@link #anchorAppliesAt}) at or above the v2 activation + * height. The producer and both rules ask THIS question, not one that resembles it. + * + * @param blockNumber the height + * @return true when the v2 form is the only accepted form at this height + */ + public boolean anchorV2AppliesAt(final long blockNumber) { + return anchorV2Block != NEVER && blockNumber >= anchorV2Block && anchorAppliesAt(blockNumber); + } + + /** + * Copy with a v2 activation height. + * + * @param v2Block the height, or {@link #NEVER} + * @return a configuration identical to this one except for the v2 height + */ + public PqAnchorConfig withAnchorV2Block(final long v2Block) { + return new PqAnchorConfig( + chainId, anchorBlock, minSealsSchedule, minSealsCeiling, disabled, maxSealsCarried, + anchorInterval, v2Block, anchorIntervalSchedule); + } + + /** + * Return a copy carrying an anchor interval. + * + *

THE SECURITY PROPERTY, stated so it can be argued with: block hashes chain, so an anchor at + * height A protects everything below A, because rewriting anything under A changes A's parent hash + * and forces A to be reproduced, which needs a Falcon quorum. What an adversary holding every + * classical validator key can still rewrite is the TAIL since the last anchor. Therefore + * {@code fork depth <= interval}. At ~523 ms a block, an interval of 100 is about 52 seconds of + * rewritable tail, against about half a second at interval 1, and it costs a hundredth of the disk. + * + * @param interval the interval, or empty for every block + * @return a copy of this configuration carrying the interval + */ + public PqAnchorConfig withAnchorInterval(final OptionalInt interval) { + return new PqAnchorConfig( + chainId, anchorBlock, minSealsSchedule, minSealsCeiling, disabled, maxSealsCarried, interval, + anchorV2Block, anchorIntervalSchedule); + } + + /** + * Whether the anchor is configured at all, INDEPENDENTLY of whether it has been emergency + * disarmed. + * + *

{@link #everActive()} answers "will the rules ever fire", which is false both when nothing is + * configured and when a configured anchor has been disarmed. Telling those two states apart is the + * whole point of the per-block disarm announcement: a node that never had an anchor has nothing to + * shout about, and a node running disarmed over a configured anchor has to shout at every block. + * + * @return true iff an activation height is configured + */ + public boolean anchorConfigured() { + return anchorBlock != NEVER; + } + + /** + * The activation height H. + * + * @return H, or {@link #NEVER} + */ + public long anchorBlock() { + return anchorBlock; + } + + /** + * The height at which the legacy log-only {@code FalconSealValidationRule} retires. It is the same + * height the new rules take over at, so exactly one of the two regimes is in force at any height. + * + * @return H, or {@link #NEVER} when the anchor never activates and the legacy rule never retires + */ + public long legacyFalconRuleRetirementBlock() { + return everActive() ? anchorBlock : NEVER; + } + + /** + * The chain id used in the D and M pre-images. + * + * @return the chain id + */ + public long chainId() { + return chainId; + } + + /** + * The staged threshold schedule. + * + * @return an unmodifiable view keyed on activation height + */ + public NavigableMap minSealsSchedule() { + return minSealsSchedule; + } + + /** + * Whether the emergency disable override is in force. + * + * @return true iff the anchor rules are switched off on this node + */ + public boolean disabled() { + return disabled; + } + + @Override + public String toString() { + return "PqAnchorConfig{chainId=" + + chainId + + ", anchorBlock=" + + (anchorBlock == NEVER ? "NEVER" : anchorBlock) + + ", minSeals=" + + minSealsSchedule + + ", ceiling=" + + (minSealsCeiling.isPresent() ? minSealsCeiling.getAsInt() : "none") + + ", disabled=" + + disabled + + '}'; + } +} diff --git a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorLapse.java b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorLapse.java new file mode 100755 index 000000000..a7c8c9bf1 --- /dev/null +++ b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorLapse.java @@ -0,0 +1,375 @@ +/* + * Copyright contributors to Besu / Aere Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.OptionalInt; + +/** + * The historical ranges on chain 2800 in which the anchor rules were not fully in force. + * + *

WHAT HAPPENED. On 2026-08-10 all nine validators were restarted inside a few minutes. A + * node that has just restarted holds no Falcon seals until it takes part in one commit, so for a + * moment every node held none; the very next height was an anchor height, its threshold asked for + * three seals, nobody could propose, and the chain stopped for nine minutes. The measure that + * restarted it was to switch the anchor rules off on all nine nodes at once, and then to restore + * them with the emergency seal ceiling lowered rather than at the scheduled threshold. That leaves + * TWO ranges of canonical headers, not one, and they fail a strict validator for different reasons: + * + *

    + *
  1. The rules off entirely. vanityData carries the client's usual version string instead + * of the anchor digest, although the certificate is attached, so the digest binding does not + * hold; and the certificate's validator indices are in the order the commit messages ARRIVED + * rather than sorted, so they are distinct but not increasing. + *
  2. The rules on, the threshold lowered. vanityData carries a correct digest and the + * indices are sorted, so both of those bind exactly as they should. What differs is the COUNT: + * a proposer running under the lowered ceiling legitimately wrote fewer seals than the + * configured schedule asks for, so the header is short of the threshold and nothing else. + *
+ * + *

WHY THE EXCEPTION BELONGS AT VALIDATION AND NOWHERE ELSE. Nodes that already hold these + * blocks never revalidate them, which is why the damage was invisible. A node built from this source + * and synced from genesis does validate them, and under the plain rules it stops at the first header + * of each range and never passes it. The history is what it is: no recomputation can turn these + * headers into headers that satisfy a rule they were written without. What a correct client can do + * is name the ranges and judge those headers by the rule that was actually in force when they were + * written, which is what every node holding the chain does today. A patch anywhere else moves the + * error instead of ending it. + * + *

THE BOUNDS, MEASURED, NOT SEARCHED. Neither property is monotone: inside the second + * range only about one anchor header in seven is short of the threshold and the rest are not, so a + * binary search would return a height that looks exactly like an answer and is not one. Every anchor + * height from the activation height 13,014,000 to the head at the time of measurement, 14,077,000, + * was therefore read one by one and its vanityData and certificate decoded: 25,252 heights, + * zero transport errors, and exactly the two contiguous ranges named below. Outside them there is + * not one header without a digest, not one with unsorted indices, and not one below the threshold. + * + *

WHAT THE RANGES DO NOT RELAX, AT ANY HEIGHT. Over the first range a certificate must + * still decode and must still carry non-negative, pairwise DISTINCT indices: sortedness fixes one + * accepted order for a given set, while distinctness is the part that is load bearing against an + * attacker, because it is what makes "repeat one seal to inflate the count" unrepresentable in the + * grammar of the format. Over the second range NOTHING is relaxed except the count, and even the + * count keeps a floor: the lowest number of seals measured anywhere in that range is + * 1, which is the ceiling that was actually in force, so that is the floor applied, + * not zero. Both ranges therefore admit exactly the history that exists and nothing weaker. + * + *

THE BOUNDS ARE FIXED IN CODE ON PURPOSE. A range a node could widen at runtime would let + * a future lapse pass unnoticed. Outside these ranges nothing changes at all, so a NEW unenforced + * header still stops this client, which is what should happen. Producers never consult this class: + * new headers are always written with the digest, with sorted indices and at the scheduled + * threshold, so the ranges are closed by construction and cannot grow. + * + *

THE TYPE IS A LIST BECAUSE A THIRD RANGE MUST NOT REQUIRE TOUCHING A RULE. The two + * entries below are the two the chain has. Adding another is an edit to this file alone. + */ +public final class PqAnchorLapse { + + /** The chain these ranges belong to. They describe no other chain. */ + public static final long CHAIN_ID = 2800L; + + /** The anchor spacing in force over both ranges, used by the self-check below. */ + public static final long ANCHOR_SPACING = 32L; + + /** What was not in force over a range. */ + public enum Relaxation { + /** Nothing was in force: no digest binding, no ordering, no threshold. */ + EVERYTHING, + /** The digest and the ordering were in force; only the seal threshold was lowered. */ + THRESHOLD_ONLY + } + + /** + * One contiguous range of heights over which the anchor rules were not fully in force. + * + *

Both bounds are INCLUSIVE and both are measured anchor heights: the first is the first header + * that shows the defect and the last is the last one that shows it. + */ + public static final class Window { + + private final long firstBlock; + private final long lastBlock; + private final long anchorHeights; + private final Relaxation relaxation; + private final int effectiveMinSeals; + private final String reason; + + /** + * Names a range. + * + * @param firstBlock the first affected height, inclusive + * @param lastBlock the last affected height, inclusive + * @param anchorHeights how many anchor heights the range spans; carried so that the count and + * the bounds are checked against each other rather than written twice + * @param relaxation what was not in force + * @param effectiveMinSeals the seal threshold that WAS in force over the range, which is the + * floor still applied inside it; zero when nothing was in force + * @param reason why the range exists, and what was measured + */ + public Window( + final long firstBlock, + final long lastBlock, + final long anchorHeights, + final Relaxation relaxation, + final int effectiveMinSeals, + final String reason) { + if (firstBlock < 0L || lastBlock < firstBlock) { + throw new IllegalArgumentException( + "AERE PQ ANCHOR LAPSE: a window must be a non-negative, non-empty range, got [" + + firstBlock + + ", " + + lastBlock + + "]"); + } + if (anchorHeights < 1L) { + throw new IllegalArgumentException( + "AERE PQ ANCHOR LAPSE: a window must span at least one anchor height, got " + + anchorHeights); + } + if (effectiveMinSeals < 0) { + throw new IllegalArgumentException( + "AERE PQ ANCHOR LAPSE: the threshold in force cannot be negative, got " + + effectiveMinSeals); + } + this.firstBlock = firstBlock; + this.lastBlock = lastBlock; + this.anchorHeights = anchorHeights; + this.relaxation = relaxation; + this.effectiveMinSeals = effectiveMinSeals; + this.reason = reason; + } + + /** + * The first affected height. + * + * @return the inclusive lower bound + */ + public long firstBlock() { + return firstBlock; + } + + /** + * The last affected height. + * + * @return the inclusive upper bound + */ + public long lastBlock() { + return lastBlock; + } + + /** + * How many anchor heights this range spans. + * + * @return the span, in anchor heights + */ + public long anchorHeights() { + return anchorHeights; + } + + /** + * What was not in force over this range. + * + * @return the relaxation + */ + public Relaxation relaxation() { + return relaxation; + } + + /** + * The seal threshold that WAS in force over this range. + * + * @return the measured floor, zero when no rule was in force + */ + public int effectiveMinSeals() { + return effectiveMinSeals; + } + + /** + * Why this range exists. + * + * @return the reason, with what was measured + */ + public String reason() { + return reason; + } + + /** + * Whether a height falls inside this range. + * + * @param blockNumber the height being judged + * @return true iff the height is within the inclusive bounds + */ + public boolean covers(final long blockNumber) { + return blockNumber >= firstBlock && blockNumber <= lastBlock; + } + + @Override + public String toString() { + return "[" + + firstBlock + + ", " + + lastBlock + + "] " + + relaxation + + " (" + + anchorHeights + + " anchor heights, threshold in force " + + effectiveMinSeals + + ")"; + } + } + + private static final List WINDOWS = + List.of( + new Window( + 13_267_824L, + 13_268_944L, + 36L, + Relaxation.EVERYTHING, + 0, + "2026-08-10: the anchor rules were switched off on the whole validator set to restart " + + "a stopped chain, so the headers produced meanwhile carry the client version " + + "string in vanityData instead of the anchor digest, and their certificates " + + "carry the indices in arrival order rather than sorted. Measured: 36 anchor " + + "heights, all 36 affected, all with distinct non-negative indices"), + new Window( + 13_268_976L, + 13_890_544L, + 19_425L, + Relaxation.THRESHOLD_ONLY, + 1, + "2026-08-10 onwards: the rules were restored immediately afterwards " + + "but with the emergency seal ceiling lowered, because raising the threshold " + + "back in one step had stopped the chain a second time. Every header here binds " + + "its digest and carries sorted indices; 2,926 of the 19,425 " + + "anchor heights in the range simply carry fewer seals than the schedule asks, " + + "the fewest being 1")); + + static { + // The list is ordered and disjoint, and each entry's stated span agrees with its own bounds. + // This runs at class initialisation so that a future entry which contradicts itself, or which + // overlaps its neighbour, fails loudly at startup rather than silently widening what a node + // accepts. + Window previous = null; + for (final Window window : WINDOWS) { + if (previous != null && window.firstBlock() <= previous.lastBlock()) { + throw new IllegalStateException( + "AERE PQ ANCHOR LAPSE: windows must be ordered and disjoint, " + + previous + + " overlaps " + + window); + } + final long derived = (window.lastBlock() - window.firstBlock()) / ANCHOR_SPACING + 1L; + if (derived != window.anchorHeights()) { + throw new IllegalStateException( + "AERE PQ ANCHOR LAPSE: window " + + window + + " says it spans " + + window.anchorHeights() + + " anchor heights, but its bounds at spacing " + + ANCHOR_SPACING + + " span " + + derived); + } + if (window.relaxation() == Relaxation.EVERYTHING && window.effectiveMinSeals() != 0) { + throw new IllegalStateException( + "AERE PQ ANCHOR LAPSE: window " + window + " has no rule in force, so no threshold"); + } + if (window.relaxation() == Relaxation.THRESHOLD_ONLY && window.effectiveMinSeals() < 1) { + throw new IllegalStateException( + "AERE PQ ANCHOR LAPSE: window " + + window + + " relaxes only the threshold, so the threshold it replaces it with must be at " + + "least 1; a floor of zero would accept an empty certificate where a certificate " + + "was in fact required"); + } + previous = window; + } + } + + private PqAnchorLapse() {} + + /** + * Every named range, ordered by first height. + * + * @return an unmodifiable list, never null, possibly empty + */ + public static List windows() { + return WINDOWS; + } + + /** + * Whether the anchor rules were out of force ENTIRELY at this height. + * + *

This is the single question both anchor rules ask before they judge anything. They ask it + * through this one method, for the same reason they share {@code anchorAppliesAt}: two rules that + * each decided the boundary their own way could disagree about one header, and a header accepted + * by one rule and refused by the other is a chain break. + * + *

It is deliberately FALSE inside a threshold-only range. There the digest binding and the + * ordering did hold and are still required; only {@link #historicMinSeals(long)} moves. + * + * @param blockNumber the height being judged + * @return true iff the height falls inside a range where nothing was in force + */ + public static boolean isDisarmed(final long blockNumber) { + for (final Window window : WINDOWS) { + if (window.covers(blockNumber) && window.relaxation() == Relaxation.EVERYTHING) { + return true; + } + } + return false; + } + + /** + * The seal threshold that was actually in force at this height, when it was lower than the + * configured schedule. + * + *

This is the same lever the fleet itself used at the time, the emergency ceiling, replayed + * from a fixed range instead of from a runtime option. A node validating history therefore asks + * the certificate for what was asked of the proposer that wrote it, and nothing more. + * + * @param blockNumber the height being judged + * @return the threshold in force, or empty when the configured schedule applies unchanged + */ + public static OptionalInt historicMinSeals(final long blockNumber) { + for (final Window window : WINDOWS) { + if (window.covers(blockNumber) && window.relaxation() == Relaxation.THRESHOLD_ONLY) { + return OptionalInt.of(window.effectiveMinSeals()); + } + } + return OptionalInt.empty(); + } + + /** + * The range covering a height, when there is one. + * + * @param blockNumber the height being judged + * @return the range, or empty + */ + public static Optional windowCovering(final long blockNumber) { + return WINDOWS.stream().filter(w -> w.covers(blockNumber)).findFirst(); + } + + /** + * The first height of the earliest named range, or empty when there is none. + * + * @return the earliest affected height + */ + public static Optional earliestAffectedBlock() { + return WINDOWS.stream().map(Window::firstBlock).min(Comparator.naturalOrder()); + } +} diff --git a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorNotReadyException.java b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorNotReadyException.java new file mode 100755 index 000000000..2501920f3 --- /dev/null +++ b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorNotReadyException.java @@ -0,0 +1,132 @@ +/* + * Copyright contributors to Besu / AERE Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +/** + * Raised by the block producer when this node cannot back the block it is about to propose with a + * certificate that reaches the staged threshold K. + * + *

THIS IS A DECISION, NOT A FAULT. The alternative, proposing anyway, means emitting a header + * this node has ALREADY COMPUTED to be invalid under its own rules. In QBFT both choices cost the + * same thing, one round timeout, because there is no way for a proposer to yield its turn early: + * a proposal everyone rejects and a proposal nobody sends both end at the round timer. So proposing + * anyway buys no liveness at all and only puts a header that cannot be imported into gossip. The + * refusal is the strictly better of two equal-cost options. + * + *

WHEN IT CAN HAPPEN. Only above the activation height, and only once the operator has raised K + * past zero: the first stage is required to be K=0, so activation itself can never refuse. In + * steady state the usual cause is a node that restarted and has not yet taken part in a commit, at + * most one proposer turn. The other cause, f validators withholding Falcon seals, is the measured + * A10 exposure and is the reason the validator set must grow to N>=9 before K is raised to + * quorum, because at N=7, f=2 the margin is exactly zero. + * + *

Callers on the consensus path must catch this and simply not propose. It carries the numbers a + * human needs to see why, so the refusal is never silent. + */ +public class PqAnchorNotReadyException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + /** + * Find a refusal anywhere in a throwable's cause chain. + * + *

MEASURED REASON THIS EXISTS, 2026-08-02. {@code AbstractBlockCreator.createBlock} wraps + * EVERY exception thrown by the extra-data calculator into {@code IllegalStateException("Block + * creation failed unexpectedly. Will restart on next block added to chain.")} at + * AbstractBlockCreator.java:366 of commit d2032017, and the producer is called from inside that + * same try block, at AbstractBlockCreator.java:333. A plain {@code catch + * (PqAnchorNotReadyException)} on the consensus path is therefore DEAD CODE. Measured on an + * isolated N=4 network with an unreachable threshold (K=4 from height 30, chain halted at 29): + * the refusal WARN was logged 0 times on 4 of 4 nodes, while "Block creation failed unexpectedly" + * and "State machine threw exception while processing event" were each logged once per node, and + * the escape aborted {@code handleRoundChangePayload} rather than skipping one proposal. + * + *

Callers must rethrow when this returns null: a refusal is the ONLY exception the consensus + * path is allowed to swallow, and a broad catch that ate the rest would hide real faults. + * + * @param thrown the exception caught on the consensus path + * @return the refusal carried by that exception, or null when it is not a refusal + */ + public static PqAnchorNotReadyException findIn(final Throwable thrown) { + Throwable cursor = thrown; + // Bounded so a self-referential or cyclic cause chain cannot spin the consensus thread. + for (int hop = 0; cursor != null && hop < 16; hop++) { + if (cursor instanceof PqAnchorNotReadyException) { + return (PqAnchorNotReadyException) cursor; + } + final Throwable next = cursor.getCause(); + if (next == cursor) { + return null; + } + cursor = next; + } + return null; + } + + private final long parentNumber; + private final int seals; + private final int required; + + /** + * Instantiates the refusal. + * + * @param parentNumber the number of the parent block the certificate would attest + * @param seals how many valid, eligible seals this node holds for that parent + * @param required the staged threshold K for the block being produced + * @param detail a human readable explanation of where the seals came from and what was rejected + */ + public PqAnchorNotReadyException( + final long parentNumber, final int seals, final int required, final String detail) { + super( + "AERE-PQ-ANCHOR-NOT-READY: refusing to propose on top of block " + + parentNumber + + " because this node holds " + + seals + + " valid eligible Falcon seal(s) for it and the staged threshold is " + + required + + ". " + + detail); + this.parentNumber = parentNumber; + this.seals = seals; + this.required = required; + } + + /** + * The parent the certificate would have attested. + * + * @return the parent block number + */ + public long parentNumber() { + return parentNumber; + } + + /** + * How many valid eligible seals were available. + * + * @return the seal count + */ + public int seals() { + return seals; + } + + /** + * The staged threshold that was not reached. + * + * @return the threshold K + */ + public int required() { + return required; + } +} diff --git a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorSyncModeGuard.java b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorSyncModeGuard.java new file mode 100755 index 000000000..2dc16d8e9 --- /dev/null +++ b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorSyncModeGuard.java @@ -0,0 +1,130 @@ +/* + * Copyright contributors to Besu / AERE Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +import java.util.Locale; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * AERE SINCRONIZARE (2026-08-02). Refuse to start when the V2 certificate anchor is configured and + * this node is going to acquire its history through a sync mode whose header import path does not + * run the block header validator at all. + * + *

The hole this closes, read in the source and not assumed. On the SNAP path the backward + * header pipeline ends in {@code + * org.hyperledger.besu.ethereum.eth.sync.common.ImportHeadersStep}. That class takes a {@code + * MutableBlockchain}, an anchor header and a pivot header, and nothing else: it holds no {@code + * ProtocolSchedule}, no {@code ProtocolContext} and no {@code BlockHeaderValidator}. Its {@code + * accept(List)} checks one thing, that the first header of the batch hashes to the parent hash the + * previously imported child declared, and then calls {@code storeBlockHeaders} directly. There is no + * {@code HeaderValidationMode} anywhere in the file. So every header between the checkpoint and the + * pivot enters the database having been checked for hash chaining and for nothing else. No QBFT + * rule runs on it: not the validator set rule, not the committed seals rule, and not either half of + * the certificate anchor. Bodies and receipts below the pivot arrive through the same pipeline + * family and are imported without executing the blocks. + * + *

Why refusal and not coverage. Covering the SNAP header path properly means giving that + * step a header validator and a parent for every header it imports. The backward pipeline does not + * have the parent when it imports a header - the parent is downloaded in a later batch - so the + * attached half of the anchor cannot run there at all, and the detached half would run against a + * parent that is not yet known. Refusing to start is the only repair that is complete: it has no + * window, no partial coverage and nothing for an operator to get subtly wrong. What it costs is + * stated plainly rather than hidden: with the anchor armed, a new public node has to acquire the + * chain by full sync. + * + *

Fail closed on the unknown. A null, blank or unrecognised mode name aborts. A guard that + * treats "I could not tell" as "probably fine" is the class of defect this whole programme exists to + * remove. + * + *

Inert when the anchor is not configured. With no {@code aere.pq.anchorBlock} this method + * returns before it looks at the sync mode, so a binary carrying it behaves exactly as today on + * chain 2800 as it stands, where the anchor is not configured on any node. + */ +public final class PqAnchorSyncModeGuard { + + private static final Logger LOG = LoggerFactory.getLogger(PqAnchorSyncModeGuard.class); + + /** The stable machine-readable code emitted when this guard refuses to start a node. */ + public static final String CODE = "AERE-PQC-SYNC-MODE-01"; + + /** The only sync mode whose import path runs the block header validator on every block. */ + public static final String REQUIRED_MODE = "FULL"; + + private PqAnchorSyncModeGuard() {} + + /** + * Refuse to start when the anchor is armed and the sync mode bypasses header validation. + * + *

Call this exactly once during node startup, from a point where a chain head already exists + * and before the network and the consensus state machine are started, so that a refusal is a clean + * refusal to start rather than a mid-flight halt. + * + * @param syncModeName the configured sync mode, as its enum name; null or blank aborts + * @param anchorConfig the height-indexed certificate-anchor configuration; a null or never-active + * configuration makes this method a no-op + * @throws FalconSealSupport.ActivationConfigException when the anchor is armed and the sync mode + * is anything other than {@value #REQUIRED_MODE} + */ + public static void verifySyncModeOrAbort( + final String syncModeName, final PqAnchorConfig anchorConfig) { + + if (anchorConfig == null || !anchorConfig.everActive() || anchorConfig.disabled()) { + LOG.debug( + "AERE PQ ANCHOR: sync-mode guard inert, the certificate anchor is not armed on this node."); + return; + } + + final String mode = + syncModeName == null ? "" : syncModeName.trim().toUpperCase(Locale.ROOT); + + if (REQUIRED_MODE.equals(mode)) { + LOG.info( + "AERE PQ ANCHOR: sync mode is {} and the certificate anchor is armed at H={}. Every header " + + "this node imports goes through the block header validator, so both halves of the " + + "anchor apply to synced history as well as to proposed blocks.", + mode, + anchorConfig.anchorBlock()); + return; + } + + throw new FalconSealSupport.ActivationConfigException( + FalconSealSupport.ActivationConfigException.Kind.UNSAFE, + CODE, + "AERE PQ ANCHOR: REFUSING TO START (fail-closed). The V2 certificate anchor is armed at " + + "H=" + + anchorConfig.anchorBlock() + + " but --sync-mode is " + + (mode.isEmpty() ? "(null or blank)" : mode) + + ", not " + + REQUIRED_MODE + + ". WHAT THIS MEANS: outside " + + REQUIRED_MODE + + " sync, history is acquired through the backward header pipeline, which ends in " + + "ImportHeadersStep. That step holds no ProtocolSchedule and no BlockHeaderValidator; " + + "it checks that each batch of headers hash-chains onto the child already imported and " + + "then writes them straight to storage. No HeaderValidationMode appears in that file. " + + "So the anchor digest rule and the anchor seals rule would both be skipped for every " + + "block between the checkpoint and the pivot, and this node would accept, as its own " + + "history, blocks whose certificates were stripped or swapped in flight. A defence that " + + "does not apply to a node that is syncing is missing exactly when it matters, because " + + "that is the moment a node takes history from strangers. WHAT TO DO: start this node " + + "with --sync-mode=" + + REQUIRED_MODE + + ". To start it unchanged for diagnosis, unset aere.pq.anchorBlock; the anchor rules " + + "are then inert and this node enforces nothing."); + } +} diff --git a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorThresholdGuard.java b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorThresholdGuard.java new file mode 100755 index 000000000..963c64b23 --- /dev/null +++ b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorThresholdGuard.java @@ -0,0 +1,282 @@ +/* + * Copyright contributors to Besu / AERE Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * AERE GARDA-PRAG (2026-08-02). Refuse to start when the staged Falcon seal threshold K is set to a + * value that STOPS THE CHAIN, instead of letting the node boot quietly and discover it one block + * later. + * + *

The hole this closes, measured before it was written. Searched across the whole fork + * tree, {@code grep -rn "minSeals" --include=*.java consensus app} filtered for any of {@code + * quorum}, {@code throw}, {@code refus}, {@code abort} or {@code Illegal}: ZERO matches. {@link + * PqAnchorConfig} refuses a NEGATIVE threshold and a step scheduled below the activation height, and + * nothing else. The only guard that looked at the threshold at all was {@code + * PqAnchorProducer.warnIfActivationHeightCanRefuse}, and it covers a different question (K at the + * activation height itself), reaches only {@code LOG.error}, and returns. A halting threshold was + * therefore a configuration a node accepted in silence. + * + *

THE BOUND, AND WHERE THE ARITHMETIC COMES FROM. The refusal is {@code K >= quorum(N)}, + * i.e. the highest value that may be configured is {@code quorum(N) - 1}. + * + *

    + *
  • {@code quorum(N)} is Besu's own {@link BftHelpers#calculateRequiredValidatorQuorum(int)}, + * {@code ceil(2N/3)}. At N=7 that is {@code ceil(14/3) = 5}, so the highest configurable + * threshold on chain 2800 as it stands is 4. + *
  • {@code K = quorum} is a GUARANTEED HALT, and that is measured, not argued. A proposer + * assembles its certificate out of the Falcon seals it heard on Commit messages, and the block + * is IMPORTED at the quorum-th Commit; after the import {@code QbftController.consumeMessage} + * discards every message that targets a height not above the chain head, so the Commits that + * arrive afterwards never reach the seal cache. A proposer can therefore gather at most {@code + * quorum} seals, at ANY N. Measured on an isolated N=4 network (quorum 3): {@code k = 3} on + * EVERY header above the activation height, never 4, with all four nodes keyed and healthy. + * Requiring K=quorum thus requires that ALL of the first {@code quorum} Commits carry a valid, + * eligible Falcon seal: one validator without a key among them, or one seal that does not + * verify, and no proposer can ever propose again. + *
  • {@code K > N - f} is the WRONG bound and this guard deliberately does not use it. At + * N=7 the two agree by coincidence, because {@code N - f = 7 - 2 = 5} equals {@code quorum} and + * both reject 5. They part company as soon as the set grows: at N=9, {@code quorum = 6} while + * {@code N - f = 7}, so {@code K > N - f} would happily accept K=7, which is two rungs above + * what a proposer can physically gather. Growing the validator set does not buy quorum margin; + * that was measured too, and it is asserted in the test. + *
+ * + *

What is a refusal and what is only a shout. Above {@code quorum - f} the schedule is + * still reachable but has no margin against f silent or keyless signers, which is the measured A10 + * and A13 exposure. That is a deliberate operator choice with a real cost, so it gets a loud WARN and + * the node starts. At or above {@code quorum} the schedule is not reachable at all, so it gets a + * refusal. A guard that refused both would take the emergency ladder away; a guard that shouted for + * both would be the log line this class exists to replace. + * + *

The verdict is computed from the SAME code path enforcement reads. Every step is scored + * with {@link PqAnchorConfig#minSealsAt(long)}, the exact method the producer calls, not with a + * second reading of the raw schedule. That is why the emergency ceiling is honoured here for free: a + * ceiling that lowers a fatal schedule below the bound makes the node start, which is precisely what + * an operator needs at three in the morning, and a ceiling ABOVE the schedule rescues nothing, + * because the ceiling can only ever lower. + * + *

Fail closed on the unknown. A validator-set size of zero or less aborts. "I could not + * count the validators" is not "the threshold is probably fine". + * + *

Inert when the anchor is not armed. With no {@code aere.pq.anchorBlock}, or with the + * anchor emergency-disarmed, this method returns before it computes anything, so a binary carrying it + * behaves exactly as today on chain 2800, where the anchor is armed on no node. + */ +public final class PqAnchorThresholdGuard { + + private static final Logger LOG = LoggerFactory.getLogger(PqAnchorThresholdGuard.class); + + /** The stable machine-readable code emitted when this guard refuses to start a node. */ + public static final String CODE = "AERE-PQC-THRESHOLD-01"; + + private PqAnchorThresholdGuard() {} + + /** + * The highest seal threshold that may be configured for a validator set of this size. + * + *

REVISED 2026-08-20, and the revision is a measurement, not an opinion. Until D-227 + * (2026-08-14) a proposer could gather at most {@code quorum} seals: the block imported at the + * quorum-th Commit and {@code QbftController.consumeMessage} discarded every later Commit, so this + * method returned {@code quorum - 1} and the class doc below carries that history. D-227 (the + * late-seal salvage, {@code PqLateSealSalvageTest}) extracts the Falcon seal BEFORE the height + * gate discards the message, so the cache now accumulates seals from every ALIVE keyed validator. + * Measured on mainnet 2800 across 5,400 anchor blocks (2026-08-18..20): certificates carry 8 and 9 + * seals at N=9, i.e. strictly more than quorum=6, which under the old mechanics was impossible. + * + *

The bound that remains fatal is availability under the tolerated fault budget: with f + * validators Byzantine or down, at most {@code N - f} seals can ever exist, so a threshold above + * {@code N - f} halts anchors inside the design's own fault model. At N=9 this is 7; at N=7 it is + * 5. A threshold at or above the quorum is now a LIVENESS TAX (anchors wait for late seals), + * shouted below, not a guaranteed halt. + * + * @param validatorCount the number of validators, at least 1 + * @return the highest configurable threshold K, {@code N - f} + */ + public static int maxConfigurableThreshold(final int validatorCount) { + return validatorCount - byzantineBudget(validatorCount); + } + + /** + * The number of faulty validators QBFT tolerates at this set size, {@code floor((N-1)/3)}. + * + * @param validatorCount the number of validators, at least 1 + * @return f + */ + public static int byzantineBudget(final int validatorCount) { + return (validatorCount - 1) / 3; + } + + /** + * Refuse to start when any step of the staged threshold schedule is at or above the QBFT quorum. + * + *

Call this exactly once during node startup, from a point where the validator set is already + * known and before the network and the consensus state machine are started, so that a refusal is a + * clean refusal to start rather than a mid-flight halt. + * + * @param anchorConfig the height-indexed certificate-anchor configuration; a null, never-active or + * disarmed configuration makes this method a no-op + * @param validatorCount the size of the validator set this node is starting into; zero or less + * aborts + * @throws FalconSealSupport.ActivationConfigException when the anchor is armed and any step of the + * effective threshold schedule reaches the quorum, or when the validator set size is unknown + */ + public static void verifyThresholdAgainstQuorumOrAbort( + final PqAnchorConfig anchorConfig, final int validatorCount) { + + if (anchorConfig == null || !anchorConfig.everActive()) { + LOG.debug( + "AERE PQ ANCHOR: threshold guard inert, the certificate anchor is not armed on this node."); + return; + } + + if (validatorCount < 1) { + throw new FalconSealSupport.ActivationConfigException( + FalconSealSupport.ActivationConfigException.Kind.UNSAFE, + CODE, + "AERE PQ ANCHOR: REFUSING TO START (fail-closed). The V2 certificate anchor is armed at H=" + + anchorConfig.anchorBlock() + + " but this node counted " + + validatorCount + + " validators, so the QBFT quorum cannot be computed and the configured seal " + + "threshold cannot be checked against it. A threshold at or above the quorum stops " + + "the chain, and a guard that treated an uncountable validator set as safe would be " + + "exactly the silence this guard exists to remove. WHAT TO DO: start this node " + + "without aere.pq.anchorBlock to diagnose, or fix whatever prevented the validator " + + "set from being read."); + } + + final int quorum = BftHelpers.calculateRequiredValidatorQuorum(validatorCount); + final int maxConfigurable = maxConfigurableThreshold(validatorCount); + final int f = byzantineBudget(validatorCount); + final int noMarginAbove = quorum - f; + + long fatalHeight = -1L; + int fatalThreshold = -1; + long highestAt = anchorConfig.anchorBlock(); + int highest = anchorConfig.minSealsAt(anchorConfig.anchorBlock()); + + for (final Map.Entry step : anchorConfig.minSealsSchedule().entrySet()) { + final long at = step.getKey(); + // Scored through minSealsAt, the SAME method the producer calls, so the emergency ceiling is + // applied here by exactly the code that will apply it in enforcement. A guard that re-derived + // the effective threshold from the raw schedule could drift away from what is in force. + final int effective = anchorConfig.minSealsAt(at); + if (effective > highest) { + highest = effective; + highestAt = at; + } + if (effective > validatorCount - f && fatalHeight < 0L) { + fatalHeight = at; + fatalThreshold = effective; + } + } + + if (fatalHeight >= 0L) { + throw new FalconSealSupport.ActivationConfigException( + FalconSealSupport.ActivationConfigException.Kind.UNSAFE, + CODE, + "AERE PQ ANCHOR: REFUSING TO START (fail-closed). The staged Falcon seal threshold reaches " + + fatalThreshold + + " at height " + + fatalHeight + + ", and the QBFT quorum for the " + + validatorCount + + " validators this node is starting into is " + + quorum + + " (ceil(2N/3)) with f = " + + f + + ". The highest threshold that may be configured at this set size is " + + maxConfigurable + + " = N - f. WHAT THIS MEANS (doctrine revised 2026-08-20 for D-227 late-seal " + + "salvage): the seal cache accumulates seals from every ALIVE keyed validator, " + + "measured on mainnet 2800 as 8-9 seals per certificate at N=9 across 5,400 anchors. " + + "But with f validators Byzantine or down - the design's own fault budget - at most " + + "N - f seals can ever exist, so a threshold of " + + fatalThreshold + + " makes anchor blocks unreachable inside the tolerated fault model. That is a halt " + + "bought by configuration, and it starts at height " + + fatalHeight + + ". WHAT TO DO: lower the step to at most " + + maxConfigurable + + " in aere.pq.anchorMinSeals, or, if the chain is already stopped, restart with " + + "--Xaere-pq-anchor-min-seals-max=" + + maxConfigurable + + " which lowers the effective threshold without editing the schedule, or with " + + "--Xaere-pq-anchor-disarm to switch the anchor rules off on this node entirely. " + + "Schedule as read: " + + anchorConfig.minSealsSchedule() + + ", emergency ceiling: " + + (anchorConfig.minSealsCeiling().isPresent() + ? Integer.toString(anchorConfig.minSealsCeiling().getAsInt()) + : "none") + + "."); + } + + if (highest >= quorum) { + LOG.warn( + "AERE PQ ANCHOR: threshold guard PASSED at a QUORUM-OR-ABOVE threshold. K reaches {} at " + + "height {}; quorum for {} validators is {} and N - f is {}. Reachability now rests " + + "on the D-227 late-seal salvage (measured on mainnet: 8-9 seals per certificate), " + + "and the margin under the fault budget is {}: with f={} validators down, anchors " + + "wait until {} of the remaining {} carry valid seals. This is the operator's " + + "explicit choice of a liveness tax for a quorum-grade certificate.", + highest, + highestAt, + validatorCount, + quorum, + validatorCount - f, + (validatorCount - f) - highest, + f, + highest, + validatorCount - f); + return; + } + + if (highest > noMarginAbove) { + LOG.warn( + "AERE PQ ANCHOR: threshold guard PASSED but the schedule has NO MARGIN. K reaches {} at " + + "height {}, the quorum for {} validators is {} and f is {}, so the largest threshold " + + "that survives f silent or keyless signers is {}. Above that line every one of the " + + "first {} committers has to carry a valid Falcon seal minus at most {}. This is " + + "allowed because it is an operator's decision, not because it is safe.", + highest, + highestAt, + validatorCount, + quorum, + f, + noMarginAbove, + quorum, + quorum - highest); + return; + } + + LOG.info( + "AERE PQ ANCHOR: threshold guard passed. K reaches {} at height {}; quorum for {} " + + "validators is {}, highest configurable threshold is {}, margin against silent " + + "signers is {} of a tolerated f={}.", + highest, + highestAt, + validatorCount, + quorum, + maxConfigurable, + noMarginAbove - highest, + f); + } +} diff --git a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorV2.java b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorV2.java new file mode 100755 index 000000000..11931438d --- /dev/null +++ b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorV2.java @@ -0,0 +1,185 @@ +/* + * AERE crypto-agility, step 2: the versioned anchor certificate. + * + * WHY A NEW FORMAT. The legacy certificate (PqAnchor.writeCertificate, live on chain 2800) is an + * RLP list of [index, signature] pairs: it cannot say WHICH mathematics signed, so it can never + * carry the founder-approved hybrid (Falcon + SLH-DSA in one certificate, decision of + * 2026-08-07). V2 tags every seal with the one-byte scheme id from SealSchemes. + * + * HOW THE TWO FORMATS CANNOT BE CONFUSED, by construction and proven in tests: + * legacy: RLP [ [idx, sig], ... ] - first element is a LIST + * v2: RLP [ 0x02, [ [scheme, idx, sig], ... ] ] - first element is a SCALAR byte + * A legacy reader entering v2 bytes finds a scalar where it demands a list and fails loudly; this + * decoder REFUSES bytes whose first element is a list (that is legacy, not a malformed v2). The + * digest uses a NEW domain string, so a v2 digest can never collide with a v1 digest over related + * content: domain separation, same discipline as ANCHOR_DOMAIN v1. + * + * CANONICAL ORDER. Seals are strictly increasing by (validatorIndex, schemeWireId). One validator + * may seal with BOTH schemes (that is the hybrid), but the same (validator, scheme) pair can + * appear only once, and any deviation from the canonical order is a decode REFUSAL, not a repair: + * a certificate with two encodings would have two digests, and a digest that depends on encoder + * mood is not a commitment. + */ +package org.hyperledger.besu.consensus.common.bft; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.List; + +import org.apache.tuweni.bytes.Bytes; +import org.apache.tuweni.bytes.Bytes32; +import org.hyperledger.besu.crypto.Hash; +import org.hyperledger.besu.ethereum.rlp.BytesValueRLPInput; +import org.hyperledger.besu.ethereum.rlp.BytesValueRLPOutput; +import org.hyperledger.besu.ethereum.rlp.RLPInput; + +/** Encoder/decoder and digest for the v2 (scheme-tagged) anchor certificate. */ +public final class PqAnchorV2 { + + /** The version scalar that opens every v2 certificate. */ + public static final int VERSION = 2; + + /** Domain for the v2 anchor digest. NEW string: v1 and v2 digests can never collide. */ + public static final String ANCHOR_DOMAIN_V2 = "AERE-PQ-ANCHOR-2"; + + /** The domain bytes written into every v2 digest preimage. */ + public static final Bytes ANCHOR_DOMAIN_V2_BYTES = + Bytes.wrap(ANCHOR_DOMAIN_V2.getBytes(StandardCharsets.UTF_8)); + + /** Canonical order: strictly increasing (validatorIndex, schemeWireId). */ + public static final Comparator CANONICAL = + Comparator.comparingInt(SchemeSeal::getValidatorIndex) + .thenComparingInt(s -> s.getSchemeWireId() & 0xff); + + /** Hard cap mirroring the legacy store's defence: a certificate is small and bounded. */ + public static final int MAX_SEALS = 64; + + private PqAnchorV2() {} + + /** Encode a v2 certificate. The input must already be in canonical order with no duplicate + * (validator, scheme) pair and only known schemes; anything else throws: an encoder that + * silently reorders would let two byte-strings claim the same certificate. */ + public static Bytes encode(final List seals) { + requireCanonical(seals); + final BytesValueRLPOutput out = new BytesValueRLPOutput(); + out.startList(); + out.writeIntScalar(VERSION); + out.writeList( + seals, + (seal, rlp) -> { + rlp.startList(); + rlp.writeIntScalar(seal.getSchemeWireId() & 0xff); + rlp.writeIntScalar(seal.getValidatorIndex()); + rlp.writeBytes(seal.getSignature()); + rlp.endList(); + }); + out.endList(); + return out.encoded(); + } + + /** Decode a v2 certificate. Throws IllegalArgumentException on ANYTHING that is not a + * well-formed, canonical, known-scheme v2 certificate - including legacy bytes, which are + * named as such in the message so the caller can tell "old format" from "garbage". */ + public static List decode(final Bytes encoded) { + if (encoded == null || encoded.isEmpty()) { + throw new IllegalArgumentException("AERE PQ V2: empty certificate bytes"); + } + final RLPInput in = new BytesValueRLPInput(encoded, false); + in.enterList(); + if (in.nextIsList()) { + throw new IllegalArgumentException( + "AERE PQ V2: first element is a list - this is a LEGACY (v1) certificate, not v2"); + } + final int version = in.readIntScalar(); + if (version != VERSION) { + throw new IllegalArgumentException( + "AERE PQ V2: unknown certificate version " + version + " (this build understands 2)"); + } + final List seals = new ArrayList<>(); + in.enterList(); + while (!in.isEndOfCurrentList()) { + if (seals.size() >= MAX_SEALS) { + throw new IllegalArgumentException( + "AERE PQ V2: certificate exceeds " + MAX_SEALS + " seals"); + } + in.enterList(); + final int scheme = in.readIntScalar(); + final int index = in.readIntScalar(); + final Bytes signature = in.readBytes(); + in.leaveList(); + if (scheme < 0 || scheme > 0xff) { + throw new IllegalArgumentException("AERE PQ V2: scheme tag out of byte range: " + scheme); + } + seals.add(new SchemeSeal((byte) scheme, index, signature)); + } + in.leaveList(); + in.leaveList(); + requireCanonical(seals); + return seals; + } + + /** The v2 anchor digest: same shape as v1 (chainId, parent number, parent hash, certificate) + * under the NEW domain, over the CANONICAL encoding. */ + public static Bytes32 anchorDigestV2( + final long chainId, + final long parentNumber, + final Bytes parentHash, + final List seals) { + if (parentNumber < 0) { + throw new IllegalArgumentException("AERE PQ V2: parentNumber must not be negative"); + } + if (parentHash == null || parentHash.size() != 32) { + throw new IllegalArgumentException("AERE PQ V2: parentHash must be 32 bytes"); + } + final BytesValueRLPOutput out = new BytesValueRLPOutput(); + out.startList(); + out.writeBytes(ANCHOR_DOMAIN_V2_BYTES); + out.writeLongScalar(chainId); + out.writeLongScalar(parentNumber); + out.writeBytes(parentHash); + out.writeBytes(encode(seals)); + out.endList(); + return Hash.keccak256(out.encoded()); + } + + /** How many DISTINCT validators sealed with the given scheme. The hybrid threshold question + * ("K of N under scheme X") is asked per scheme, and a validator counts once per scheme no + * matter what canonicality allowed. */ + public static int distinctValidatorsWith(final Collection seals, final byte wireId) { + return (int) + seals.stream() + .filter(s -> s.getSchemeWireId() == wireId) + .mapToInt(SchemeSeal::getValidatorIndex) + .distinct() + .count(); + } + + private static void requireCanonical(final List seals) { + if (seals == null) { + throw new IllegalArgumentException("AERE PQ V2: null seal list"); + } + SchemeSeal prev = null; + for (final SchemeSeal s : seals) { + if (s.getValidatorIndex() < 0) { + throw new IllegalArgumentException( + "AERE PQ V2: negative validator index " + s.getValidatorIndex()); + } + if (SealSchemes.byWireId(s.getSchemeWireId()).isEmpty()) { + throw new IllegalArgumentException( + "AERE PQ V2: unknown scheme tag 0x" + + Integer.toHexString(s.getSchemeWireId() & 0xff) + + " - refusing the whole certificate, an unknown scheme must be loud"); + } + if (prev != null && CANONICAL.compare(prev, s) >= 0) { + throw new IllegalArgumentException( + "AERE PQ V2: seals not in strictly increasing (validator, scheme) order: " + + prev + + " then " + + s); + } + prev = s; + } + } +} diff --git a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqRegistryBinding.java b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqRegistryBinding.java new file mode 100755 index 000000000..780bf8360 --- /dev/null +++ b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqRegistryBinding.java @@ -0,0 +1,496 @@ +/* + * Copyright contributors to Besu. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +import org.hyperledger.besu.crypto.SECPSignature; +import org.hyperledger.besu.crypto.SignatureAlgorithmFactory; +import org.hyperledger.besu.datatypes.Address; +import org.hyperledger.besu.datatypes.Hash; +import org.hyperledger.besu.ethereum.core.Util; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.List; + +import org.apache.tuweni.bytes.Bytes; +import org.apache.tuweni.bytes.Bytes32; +import org.bouncycastle.crypto.digests.KeccakDigest; +import org.bouncycastle.pqc.crypto.falcon.FalconParameters; +import org.bouncycastle.pqc.crypto.falcon.FalconPublicKeyParameters; +import org.bouncycastle.pqc.crypto.falcon.FalconSigner; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * AERE D-146: bind each registry row's FALCON PUBLIC KEY to the VALIDATOR ADDRESS it sits next to. + * + *

The defect, measured on the real code path on 2026-08-06

+ * + *

{@link PqRegistryHash} makes the registry a chain-committed object: two nodes cannot hold + * different files without one of them refusing to start. It says so itself, in its own class + * javadoc: "It says nothing about whether any validator actually holds the private key matching its + * registered public key." That sentence is the whole of D-146. + * + *

Concretely, the registry is a table from index i to the pair (ECDSA validator address, + * Falcon public key). Seal verification uses the KEY at index i; signer eligibility is checked + * against the parent block's VALIDATOR SET, i.e. against the ADDRESS at index i. Nothing whatsoever + * connects the two halves of a row, so whoever writes the registry file may place any key under any + * address. Four consequences were measured with probes on the real verification path, all with the + * startup gate reporting MATCH and the header ACCEPTED: + * + *

    + *
  • T3: rebinding one row's address credits a seal made by validator 0's key to validator 1. + *
  • T4: putting ONE public key at TWO indices lets a SINGLE key holder produce two seals that + * are credited to two different addresses, so the threshold K is satisfied by one signer. The + * threshold itself becomes fiction, not merely the attribution. + *
  • T6A: SWAPPING two rows' public keys - no duplicate key, no duplicate address - is accepted. + *
  • T6B: SWAPPING two rows' addresses - again no duplicate of anything - is accepted, and every + * seal is then attributed to the wrong validator. + *
+ * + *

T6A and T6B are why this class exists and why a uniqueness check alone was not enough. Measured + * on 2026-08-06: both permuted registries carry 4 distinct public keys and 4 distinct addresses, so + * every uniqueness test passes, and the forged header is still ACCEPTED. Uniqueness restores the + * THRESHOLD (K verifying seals at K distinct indices are then necessarily K distinct keys); it can + * never restore ATTRIBUTION. Attribution needs a signature. + * + *

What a row must now carry

+ * + *

Two proofs over ONE canonical pre-image, because one signature closes only half the defect: + * + *

    + *
  • POSSESSION, a Falcon signature by the row's own key. Proves the row is not an invented or + * truncated key and that somebody holds the matching secret. It does NOT close T3, T4 or T6: + * at the key ceremony the registry writer holds every Falcon secret, so it can sign a + * possession proof for key 0 sitting under validator 1's address. Anyone who claims a Falcon + * proof-of-possession repairs D-146 is wrong, and the probe measures it. + *
  • CLAIM, an ECDSA signature by the row's own VALIDATOR key. This is the half that cuts. The + * registry writer cannot forge it without validator i's consensus key, so a key cannot be + * moved under another validator's address, indices cannot be swapped, and a key cannot appear + * under an address whose owner did not ask for it. + *
+ * + *

The canonical pre-image

+ * + *
+ *   "AERE-PQ-POP-1"            13 bytes ASCII, at offset 0
+ *   uint8(formatVersion)        1
+ *   uint64be(chainId)           8
+ *   uint64be(bindHeight)        8   the height in pqRegistryHash that makes THIS registry active
+ *   uint32be(count)             4
+ *   uint32be(index)             4
+ *   address                    20
+ *   uint32be(len(publicKey))    4
+ *   publicKey                 len
+ * 
+ * + *

Every field earns its place against a specific, named attack: + * + *

    + *
  • THE DOMAIN TAG, at offset 0 and inside the hashed pre-image rather than beside it. The same + * Falcon key signs anchor seals over {@code keccak(RLP["AERE-PQ-COMMIT-1", chainId, number, + * hash])} - also a 32-byte digest, also handed to the same {@link FalconSigner}. Without a + * distinct tag a possession proof would be a valid Falcon signature over a 32-byte digest and + * could be replayed as a SEAL, filling the threshold K with a validator that never ran a + * block. Ristenpart and Yilek (EUROCRYPT 2007) is the general form of this: a + * proof-of-possession that can be reused as a working signature is not a defence, it is a + * second door, and the repair is separate hash inputs for the two purposes. + *
  • publicKey IS IN THE SIGNED MESSAGE. Falcon is not Dilithium here: Cremers, Duzlu, Fiedler, + * Fischlin and Janson (IEEE S&P 2021) measured that Falcon does NOT have exclusive + * ownership, because {@code HashToPoint(r || m)} never absorbs the public key. Putting the key + * into the message is the Pornin-Stern PS-3 transform applied at the application layer, with + * no change to the Falcon implementation and no growth in signature size. NOT MEASURED HERE: a + * concrete key-substitution forgery against Falcon-512 at our parameters was not executed; + * what is established is the missing property, from the literature. + *
  • chainId, from Ethereum's own scar. {@code DOMAIN_DEPOSIT} omits the genesis validators root, + * so deposit signatures replay BETWEEN CHAINS; EIP-8205 exists to add the separation. Our + * scratch chain 442807 runs the same binaries and can run the same Falcon keys. + *
  • index and bindHeight, so a row cannot be lifted to a different index, and a row retired at + * one rotation cannot be replayed into a later registry. + *
  • count, so a registry cannot be truncated and have its surviving proofs stay valid. + *
+ * + *

The CLAIM signs {@code keccak256(0x19 || 0x00 || context || preimage)}, the EIP-191 version-0 + * envelope, with {@code context} the low 20 bytes of {@code keccak256("AERE-PQ-CLAIM-1" || + * uint64be(chainId))}. The leading {@code 0x19} is the point: it cannot begin an RLP-encoded legacy + * transaction (those start at {@code 0xc0}) nor a typed one ({@code 0x01}..{@code 0x04}), so a + * validator's claim can never be replayed as a transaction it signed. The context byte string keeps + * this registry's claims disjoint from any other EIP-191 message on the same chain. + * + *

Where this runs, and where it deliberately does not

+ * + *

AT LOAD, ONCE, from {@code PqRegistryHash.assemble}, which is the single point every loader + * funnels through, and fail-closed. The registry is immutable for the whole of an epoch, so the cost + * is N Falcon verifications plus N ecrecover per PROCESS START and exactly ZERO per block. At N=7, + * against the 0.068 ms median quoted in {@code PqAnchorSealsRule}, that is under a millisecond of + * startup. + * + *

NOT per seal. That would add K verifications to every block - about 0.2 ms at K=3 out of a + * 523 ms block - to re-establish a fact that cannot change within an epoch, and every line added to + * that loop is a permanent reconciliation cost against upstream Besu. + * + *

What this does NOT defend against, stated plainly

+ * + *
    + *
  • NOT the holder of the vault. Every validator ECDSA key lives in one place; whoever has + * them signs a perfectly valid claim for any Falcon key they like. This moves the attack from + * "whoever can edit a file" to "whoever holds the consensus keys". The answer to "how many + * independent people must agree to stop this chain" is unchanged, and is one. + *
  • NOT the rewriting of historical epochs. The immutable anchor contract pins ONE hash, the + * head registry's at activation; earlier scheduled epochs are pinned by nothing on chain. + *
  • NOT key duplication. Possession is not exclusivity: a claim proves validator i asked for + * this key, never that nobody else has a copy. T4 is closed by the uniqueness rule in {@code + * PqRegistryHash.assemble}, not by either signature. + *
  • NOT anything about how a private key is stored or whether it was ever copied. + *
+ */ +public final class PqRegistryBinding { + + private static final Logger LOG = LoggerFactory.getLogger(PqRegistryBinding.class); + + /** Domain tag of the binding pre-image. Distinct from every other Falcon message we sign. */ + public static final String DOMAIN_POSSESSION = "AERE-PQ-POP-1"; + + /** Domain tag mixed into the EIP-191 context of the ECDSA claim. */ + public static final String DOMAIN_CLAIM = "AERE-PQ-CLAIM-1"; + + /** Registry format version that carries binding proofs. Version 1 carries none. */ + public static final int FORMAT_VERSION = 2; + + /** r(32) || s(32) || recId(1), the encoding {@code decodeSignature} expects. */ + private static final int CLAIM_SIGNATURE_LENGTH = 65; + + private PqRegistryBinding() {} + + // =================================================================================== + // The canonical binding pre-image and its two digests + // =================================================================================== + + /** + * The canonical binding pre-image for one registry row. Domain-separated, length-prefixed, + * chain-bound, height-bound and index-bound. See the class javadoc for why each field is there. + * + * @param chainId the chain this registry is bound to + * @param bindHeight the pqRegistryHash schedule height that makes this registry active + * @param count the number of rows in the registry + * @param index the row index + * @param address the 20-byte validator address of the row + * @param publicKey the Falcon public key of the row + * @return the pre-image bytes + */ + public static byte[] bindingPreimage( + final long chainId, + final long bindHeight, + final int count, + final int index, + final byte[] address, + final byte[] publicKey) { + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + writeAll(out, DOMAIN_POSSESSION.getBytes(StandardCharsets.US_ASCII)); + out.write(FORMAT_VERSION); + writeAll(out, uint64be(chainId)); + writeAll(out, uint64be(bindHeight)); + writeAll(out, uint32be(count)); + writeAll(out, uint32be(index)); + writeAll(out, address); + writeAll(out, uint32be(publicKey.length)); + writeAll(out, publicKey); + return out.toByteArray(); + } + + /** + * The 32-byte message the row's FALCON key signs. + * + * @param chainId the chain id + * @param bindHeight the activation height of this registry + * @param count the registry size + * @param index the row index + * @param address the row's validator address + * @param publicKey the row's Falcon public key + * @return the possession digest + */ + public static Bytes32 possessionDigest( + final long chainId, + final long bindHeight, + final int count, + final int index, + final byte[] address, + final byte[] publicKey) { + return Bytes32.wrap( + keccak(bindingPreimage(chainId, bindHeight, count, index, address, publicKey))); + } + + /** + * The 32-byte message the row's VALIDATOR ECDSA key signs: the EIP-191 version-0 envelope around + * the same pre-image. The leading {@code 0x19} makes the claim unrepresentable as a transaction. + * + * @param chainId the chain id + * @param bindHeight the activation height of this registry + * @param count the registry size + * @param index the row index + * @param address the row's validator address + * @param publicKey the row's Falcon public key + * @return the claim digest + */ + public static Bytes32 claimDigest( + final long chainId, + final long bindHeight, + final int count, + final int index, + final byte[] address, + final byte[] publicKey) { + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + out.write(0x19); + out.write(0x00); + writeAll(out, claimContext(chainId)); + writeAll(out, bindingPreimage(chainId, bindHeight, count, index, address, publicKey)); + return Bytes32.wrap(keccak(out.toByteArray())); + } + + /** + * The 20-byte EIP-191 version-0 "intended validator" field for this chain's registry claims. + * + * @param chainId the chain id + * @return 20 context bytes + */ + public static byte[] claimContext(final long chainId) { + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + writeAll(out, DOMAIN_CLAIM.getBytes(StandardCharsets.US_ASCII)); + writeAll(out, uint64be(chainId)); + final byte[] d = keccak(out.toByteArray()); + final byte[] ctx = new byte[20]; + System.arraycopy(d, 12, ctx, 0, 20); + return ctx; + } + + // =================================================================================== + // Verification of one row + // =================================================================================== + + /** + * Verify a Falcon possession proof. Never throws. + * + * @param publicKey the row's raw Falcon-512 public key + * @param digest the possession digest + * @param signature the Falcon signature bytes + * @return true iff the signature verifies under that key + */ + public static boolean verifyPossession( + final byte[] publicKey, final Bytes32 digest, final byte[] signature) { + if (publicKey == null || digest == null || signature == null || signature.length == 0) { + return false; + } + try { + final FalconPublicKeyParameters pub = + new FalconPublicKeyParameters(FalconParameters.falcon_512, publicKey); + final FalconSigner verifier = new FalconSigner(); + verifier.init(false, pub); + return verifier.verifySignature(digest.toArray(), signature); + } catch (final RuntimeException e) { + LOG.debug("AERE PQC D-146: Falcon possession verify threw: {}", e.toString()); + return false; + } + } + + /** + * Recover the address that produced an ECDSA claim. Never throws. + * + * @param digest the claim digest + * @param signature 65 bytes, r || s || recId + * @return the recovered address, or null when the signature is malformed or unrecoverable + */ + public static Address recoverClaim(final Bytes32 digest, final byte[] signature) { + if (digest == null || signature == null || signature.length != CLAIM_SIGNATURE_LENGTH) { + return null; + } + try { + final SECPSignature s = + SignatureAlgorithmFactory.getInstance().decodeSignature(Bytes.wrap(signature)); + return Util.signatureToAddress(s, Hash.wrap(digest)); + } catch (final RuntimeException e) { + LOG.debug("AERE PQC D-146: claim recovery threw: {}", e.toString()); + return null; + } + } + + // =================================================================================== + // Verification of a whole registry, fail-closed + // =================================================================================== + + /** + * Verify every binding proof in a proof-bound registry, and REFUSE on the first row that does not + * hold. Called from {@code PqRegistryHash.assemble}, so it runs on every load, on every node, at + * every restart, and not merely once at the key ceremony. + * + *

Fail-closed is the whole point and it is not a preference. The neighbouring defect recorded + * on 2026-08-06 is a configuration path that fails OPEN: a misplaced comma drops the schedule, the + * threshold falls to zero, and the node starts DISARMED with nothing visible anywhere. A binding + * check that logged and continued would be the same mistake with a different name. + * + * @param registry the loaded registry + * @throws PqRegistryHash.RegistryConfigException when any row's binding does not hold + */ + public static void verifyOrThrow(final PqRegistryHash.Registry registry) { + if (!registry.proofBound()) { + return; + } + final String source = registry.sourcePath(); + if (!registry.addressBound()) { + throw new PqRegistryHash.RegistryConfigException( + "AERE-PQC-REG-BIND-06", + "AERE PQC D-146: registry '" + + source + + "' carries binding proofs but is NOT address-bound. A proof binds a Falcon key to a " + + "validator ADDRESS; with no addresses there is nothing to bind to. Refusing " + + "(fail-closed)."); + } + final long chainId = registry.declaredChainId(); + final long bindHeight = registry.bindHeight(); + final int count = registry.count(); + final List entries = registry.entries(); + + for (final PqRegistryHash.Entry e : entries) { + final byte[] addr = e.address(); + final byte[] pk = e.publicKey(); + final byte[] pop = e.possessionProof(); + final byte[] claim = e.claimProof(); + + if (pop == null || pop.length == 0) { + throw new PqRegistryHash.RegistryConfigException( + "AERE-PQC-REG-BIND-01", + "AERE PQC D-146: registry '" + + source + + "' index " + + e.index() + + " has NO possession proof while other rows have one. Binding must be all or " + + "nothing: one unproven row is a row whose key nobody has shown they hold, and it " + + "counts toward the threshold exactly like a proven one. Refusing (fail-closed)."); + } + if (claim == null || claim.length == 0) { + throw new PqRegistryHash.RegistryConfigException( + "AERE-PQC-REG-BIND-02", + "AERE PQC D-146: registry '" + + source + + "' index " + + e.index() + + " has NO validator claim. The claim is the half that stops a key being filed " + + "under somebody else's address. Refusing (fail-closed)."); + } + + final Bytes32 popDigest = + possessionDigest(chainId, bindHeight, count, e.index(), addr, pk); + if (!verifyPossession(pk, popDigest, pop)) { + throw new PqRegistryHash.RegistryConfigException( + "AERE-PQC-REG-BIND-03", + "AERE PQC D-146: registry '" + + source + + "' index " + + e.index() + + " (address 0x" + + hex(addr) + + ") carries a possession proof that DOES NOT VERIFY under the public key on the " + + "same row, for chainId=" + + chainId + + " bindHeight=" + + bindHeight + + " count=" + + count + + ". Either the key was replaced without re-signing, or the row was lifted from " + + "another index, another height or another chain. Refusing (fail-closed)."); + } + + final Bytes32 clDigest = claimDigest(chainId, bindHeight, count, e.index(), addr, pk); + final Address recovered = recoverClaim(clDigest, claim); + if (recovered == null) { + throw new PqRegistryHash.RegistryConfigException( + "AERE-PQC-REG-BIND-05", + "AERE PQC D-146: registry '" + + source + + "' index " + + e.index() + + " carries a claim of " + + claim.length + + " bytes that cannot be decoded as an ECDSA signature (expected exactly " + + CLAIM_SIGNATURE_LENGTH + + ": r||s||recId). Refusing (fail-closed)."); + } + final Address bound = Address.wrap(Bytes.wrap(addr)); + if (!recovered.equals(bound)) { + throw new PqRegistryHash.RegistryConfigException( + "AERE-PQC-REG-BIND-04", + "AERE PQC D-146: registry '" + + source + + "' index " + + e.index() + + " is bound to validator " + + bound + + " but its claim was signed by " + + recovered + + ". THIS IS THE DEFECT D-146 EXISTS FOR: the Falcon key on this row was filed " + + "under an address whose owner did not sign for it, so every seal made with that " + + "key would be credited to the wrong validator - and if the same key sits at two " + + "indices, a single key holder alone satisfies the quorum threshold. Refusing " + + "(fail-closed). WHAT TO DO: have validator " + + bound + + " sign the row with its own consensus key, or put the key back under the address " + + "that did sign it."); + } + } + LOG.info( + "AERE PQC D-146: registry '{}' - all {} rows carry a verified Falcon possession proof and a " + + "verified validator claim (chainId={}, bindHeight={}).", + source, + count, + chainId, + bindHeight); + } + + // =================================================================================== + // Local helpers. Same shape as PqRegistryHash's, deliberately, so the two files can be read + // against each other without holding two conventions in your head. + // =================================================================================== + + private static void writeAll(final ByteArrayOutputStream out, final byte[] b) { + out.write(b, 0, b.length); + } + + private static byte[] uint32be(final int v) { + return new byte[] {(byte) (v >>> 24), (byte) (v >>> 16), (byte) (v >>> 8), (byte) v}; + } + + private static byte[] uint64be(final long v) { + final byte[] b = new byte[8]; + for (int i = 0; i < 8; i++) { + b[i] = (byte) (v >>> (56 - 8 * i)); + } + return b; + } + + private static byte[] keccak(final byte[] input) { + final KeccakDigest kd = new KeccakDigest(256); + kd.update(input, 0, input.length); + final byte[] d = new byte[32]; + kd.doFinal(d, 0); + return d; + } + + private static String hex(final byte[] b) { + final StringBuilder s = new StringBuilder(b.length * 2); + for (final byte x : b) { + s.append(Character.forDigit((x >> 4) & 0xf, 16)).append(Character.forDigit(x & 0xf, 16)); + } + return s.toString(); + } +} diff --git a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqRegistryHash.java b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqRegistryHash.java new file mode 100755 index 000000000..01ee406ad --- /dev/null +++ b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqRegistryHash.java @@ -0,0 +1,2339 @@ +/* + * Copyright contributors to Besu. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.Properties; +import java.util.TreeMap; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.base.Splitter; +import org.bouncycastle.crypto.digests.KeccakDigest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * AERE A8: bind the Falcon validator-index-to-public-key REGISTRY to consensus. + * + *

THE DEFECT THIS EXISTS TO CLOSE. Measured by reading {@code FalconSealSupport}: the registry + * that answers "which Falcon public key is validator index i" can be loaded from a plain local + * properties FILE named by {@code aere.falcon.registry} / {@code AERE_FALCON_REGISTRY}. Nothing ties + * that file to the chain. Two nodes handed two different files disagree about who index i is, so the + * SAME certificate verifies on one node and fails on the other. That is a network split with no + * attacker in it at all: an operator copying the wrong file is enough. The genesis-anchored and + * late-anchor sources do check a hash, but their failure mode is to leave the registry EMPTY and log + * an error, and the process still STARTS - so a node can run for a long time believing it is a + * verifying validator while it can verify nothing. + * + *

THE FIX. A canonical hash of the registry, {@code pqRegistryHash}, is fixed in the genesis + * configuration and bound to HEIGHT. At startup a node computes the canonical hash of whatever + * registry it actually loaded and compares it with the hash the schedule makes active at its chain + * head. On a mismatch the node REFUSES TO START, and the refusal states what it found and what it + * expected, entry by entry, so an operator can find the wrong file in one read instead of diffing + * seven nodes by hand. + * + *

WHY HEIGHT-BOUND. Chain 2800 has roughly 11.8 million blocks that were produced with no + * registry binding at all. A schedule whose first entry is at height H leaves every height below H + * completely unenforced, so nothing existing is invalidated, and a later entry expresses a key + * rotation. This is the same shape as every other fork gate in this codebase: the gate is on the + * block NUMBER, never on content. + * + *

WHAT THIS CLASS DOES NOT DO, stated plainly so nobody relies on absent protection: + * + *

    + *
  • The guard runs at STARTUP. It cannot stop a node that is already running when a scheduled + * rotation height arrives underneath it. {@link #requiredHashAt(Schedule, long)} and {@link + * #matchesAt} are public precisely so a per-block header rule can enforce the same binding on + * the consensus path once such a rule exists. No such rule exists today. + *
  • It does not check that the registry is the RIGHT one in any sense other than "it hashes to + * the value the chain's genesis says it must". The genesis value is a root of trust exactly + * like the genesis hash itself. + *
  • It says nothing about whether any validator actually holds the private key matching its + * registered public key. + *
+ */ +public final class PqRegistryHash { + + private static final Logger LOG = LoggerFactory.getLogger(PqRegistryHash.class); + + /** Domain tag of the canonical v1 pre-image. Any change here is a new format and a new height. */ + public static final String DOMAIN_V1 = "AERE-PQ-REGISTRY-1"; + + /** + * Domain tag of the canonical v2 pre-image: the same registry PLUS the D-146 binding proofs. A + * separate tag, and not a flag inside v1, so that a v1 file and a v2 file can never hash equal and + * a downgrade that strips the proofs cannot satisfy a schedule entry that was written for v2. + */ + public static final String DOMAIN_V2 = "AERE-PQ-REGISTRY-2"; + + /** + * Expected length of the Falcon-512 public key as THIS registry format stores it: the raw {@code + * h} polynomial, 512 coefficients at 14 bits, i.e. 896 bytes. MEASURED: every entry of + * {@code opt/aere-pqc/falcon-registry.properties} is 896 bytes. The 897 figure that appears in + * some notes is the standard encoding, which prepends a one-byte header; that header is NOT in + * these files, and {@code FalconSealSupport} feeds the bytes straight into + * {@code new FalconPublicKeyParameters(falcon_512, h)}, which also wants the bare {@code h}. + * + *

Used only for a diagnostic warning. It never rejects, because the canonical form is + * length-prefixed and is correct for any length. + */ + private static final int FALCON_512_PK_LENGTH = 896; + + /** Max registry entries printed in a refusal message before it is elided. */ + private static final int MAX_ENTRIES_IN_REPORT = 64; + + private PqRegistryHash() {} + + // =================================================================================== + // Registry model + // =================================================================================== + + /** Where a loaded registry came from. Part of every diagnostic, because it decides trust. */ + public enum SourceKind { + /** {@code aere.falcon.registry}: a plain local properties file. No binding of its own. */ + LEGACY_PROPERTIES, + /** {@code aere.falcon.manifest}: the address-bound JSON manifest of the late-anchor path. */ + MANIFEST_JSON, + /** {@code aere.falcon.genesis}: the {@code config.aereFalconRegistry} manifest in genesis. */ + GENESIS_MANIFEST, + /** No registry configured at all. */ + NONE + } + + /** One registry row: a validator index, its Falcon public key, and optionally its address. */ + public static final class Entry { + private final int index; + private final byte[] address; // 20 bytes, or null when the source is not address-bound + private final byte[] publicKey; + private final byte[] possessionProof; // D-146, or null in a v1 registry + private final byte[] claimProof; // D-146, or null in a v1 registry + + Entry(final int index, final byte[] address, final byte[] publicKey) { + this(index, address, publicKey, null, null); + } + + Entry( + final int index, + final byte[] address, + final byte[] publicKey, + final byte[] possessionProof, + final byte[] claimProof) { + this.index = index; + this.address = address; + this.publicKey = publicKey; + this.possessionProof = possessionProof; + this.claimProof = claimProof; + } + + /** + * The validator index this row binds. + * + * @return the index + */ + public int index() { + return index; + } + + /** + * The bound validator address, or null when the source carries public keys only. + * + * @return 20 address bytes, or null + */ + public byte[] address() { + return address == null ? null : address.clone(); + } + + /** + * The Falcon-512 public key bytes. + * + * @return the public key + */ + public byte[] publicKey() { + return publicKey.clone(); + } + + /** + * D-146. The Falcon signature by this row's own key over the binding pre-image, proving somebody + * holds the matching secret. + * + * @return the possession proof, or null in a v1 registry + */ + public byte[] possessionProof() { + return possessionProof == null ? null : possessionProof.clone(); + } + + /** + * D-146. The ECDSA signature by this row's own VALIDATOR key over the binding pre-image. This is + * the half a registry writer cannot forge, and therefore the half that closes T3 and T6. + * + * @return the claim proof, or null in a v1 registry + */ + public byte[] claimProof() { + return claimProof == null ? null : claimProof.clone(); + } + } + + /** + * A loaded registry in canonical order, with the provenance needed to explain a refusal. + * + *

Loading is STRICT and fail-closed. A registry whose shape is ambiguous has no well defined + * hash, and hashing an ambiguous object would produce a number that looks authoritative and means + * nothing. Every loader therefore rejects rather than guesses: {@code count} must be present, + * indices must be exactly {@code 0..count-1} with no gap and no extra, address binding must be + * all-or-nothing, and hex must be even-length and strictly hexadecimal. + */ + public static final class Registry { + private final SourceKind kind; + private final String sourcePath; + private final List entries; // ascending index, contiguous from 0 + private final boolean addressBound; + private final boolean proofBound; // D-146: every row carries both binding proofs + private final long declaredChainId; // D-146: the chainId the proofs were signed over + private final long bindHeight; // D-146: the activation height the proofs were signed over + + Registry( + final SourceKind kind, + final String sourcePath, + final List entries, + final boolean addressBound) { + this(kind, sourcePath, entries, addressBound, false, -1L, -1L); + } + + Registry( + final SourceKind kind, + final String sourcePath, + final List entries, + final boolean addressBound, + final boolean proofBound, + final long declaredChainId, + final long bindHeight) { + this.kind = kind; + this.sourcePath = sourcePath; + this.entries = entries; + this.addressBound = addressBound; + this.proofBound = proofBound; + this.declaredChainId = declaredChainId; + this.bindHeight = bindHeight; + } + + /** + * Where this registry was loaded from. + * + * @return the source kind + */ + public SourceKind kind() { + return kind; + } + + /** + * The file this registry was read from. + * + * @return the path as configured, or "(none)" + */ + public String sourcePath() { + return sourcePath == null ? "(none)" : sourcePath; + } + + /** + * Number of registry rows. + * + * @return the entry count + */ + public int count() { + return entries.size(); + } + + /** + * The rows, ascending by index. + * + * @return an unmodifiable list + */ + public List entries() { + return List.copyOf(entries); + } + + /** + * Whether every row carries a validator address. + * + * @return true iff address-bound + */ + public boolean addressBound() { + return addressBound; + } + + /** + * D-146. Whether every row carries a verified Falcon possession proof and a verified validator + * claim. False for every registry written before 2026-08-06. + * + * @return true iff proof-bound + */ + public boolean proofBound() { + return proofBound; + } + + /** + * D-146. The chain id the binding proofs were signed over, as the FILE declares it. Cross-checked + * against the node's real chain id by {@link #verifyOrAbort}: a registry lifted from the scratch + * chain carries proofs that verify perfectly among themselves and belong to another chain. + * + * @return the declared chain id, or -1 when not proof-bound + */ + public long declaredChainId() { + return declaredChainId; + } + + /** + * D-146. The pqRegistryHash schedule height the binding proofs were signed over, so a row + * retired at one rotation cannot be replayed into a later registry. + * + * @return the bind height, or -1 when not proof-bound + */ + public long bindHeight() { + return bindHeight; + } + } + + // =================================================================================== + // Canonical encodings + // =================================================================================== + + /** + * The canonical v1 pre-image: domain-separated, length-prefixed, chain-bound. + * + *

+   *   "AERE-PQ-REGISTRY-1"          18 bytes, ASCII
+   *   uint64be(chainId)              8 bytes
+   *   uint32be(count)                4 bytes
+   *   for i in 0..count-1:
+   *       uint32be(i)                4 bytes
+   *       uint8(addressBound?1:0)    1 byte
+   *       address                   20 bytes, only when address-bound
+   *       uint32be(len(publicKey))   4 bytes
+   *       publicKey                 len bytes
+   * 
+ * + *

Three properties this buys, and each one is the answer to a specific way the older + * concatenation form could be argued with: + * + *

    + *
  • LENGTH PREFIXES. The legacy form concatenates raw keys with no separator. It is + * unambiguous only because Falcon-512 public keys happen to be a fixed 897 bytes. "Happens + * to be" is not a proof, and a future parameter set or a truncated key would silently make + * two different registries hash the same. With a length prefix in front of every variable + * field that argument cannot be made at all. + *
  • THE INDEX IS IN THE PRE-IMAGE. Not just the order. A registry that permutes two rows + * hashes differently even under a hash function that ignored order. + *
  • chainId IS IN THE PRE-IMAGE. The scratch chain 442807 runs the same binaries and can run + * the same Falcon keys; without chainId a registry lifted from there would hash equal here. + *
+ * + * @param registry the loaded registry + * @param chainId the chain id this registry is being bound to + * @return the pre-image bytes + */ + public static byte[] canonicalPreimageV1(final Registry registry, final long chainId) { + final java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream(); + writeAll(out, DOMAIN_V1.getBytes(StandardCharsets.US_ASCII)); + writeAll(out, uint64be(chainId)); + writeAll(out, uint32be(registry.count())); + for (final Entry e : registry.entries) { + writeAll(out, uint32be(e.index)); + out.write(registry.addressBound ? 1 : 0); + if (registry.addressBound) { + writeAll(out, e.address); + } + writeAll(out, uint32be(e.publicKey.length)); + writeAll(out, e.publicKey); + } + return out.toByteArray(); + } + + /** + * keccak256 of the canonical v1 pre-image, as 64-hex with no {@code 0x}. + * + * @param registry the loaded registry + * @param chainId the chain id this registry is bound to + * @return the canonical hash, lower-case, unprefixed + */ + public static String hashV1(final Registry registry, final long chainId) { + return keccakHex(canonicalPreimageV1(registry, chainId)); + } + + /** + * D-146. The canonical v2 pre-image: everything v1 commits to, plus the activation height and the + * two binding proofs of every row. + * + *
+   *   "AERE-PQ-REGISTRY-2"          18 bytes, ASCII
+   *   uint64be(chainId)              8 bytes
+   *   uint64be(bindHeight)           8 bytes
+   *   uint32be(count)                4 bytes
+   *   for i in 0..count-1:
+   *       uint32be(i)                4 bytes
+   *       uint8(1)                   1 byte, address binding is mandatory in v2
+   *       address                   20 bytes
+   *       uint32be(len(publicKey))   4 bytes
+   *       publicKey                 len bytes
+   *       uint32be(len(possession))  4 bytes
+   *       possession                len bytes
+   *       uint32be(len(claim))       4 bytes
+   *       claim                     len bytes
+   * 
+ * + *

WHY THE PROOFS ARE INSIDE THE HASH and not merely checked at load. The hash is the value that + * genesis anchors and, at arming, that the immutable anchor contract pins. If the proofs sat + * outside it, they would be advisory: a node could be handed the same registry with the proof + * fields deleted, it would hash the same, satisfy the schedule, and load without ever verifying + * anything. With them inside, stripping a proof is a different registry with a different hash and + * the existing A8 guard refuses it. That is also why v2 has its OWN domain tag: a v1 file cannot + * collide with a v2 schedule entry, so a format downgrade is refused by machinery that already + * exists rather than by a new rule that could be forgotten. + * + * @param registry the loaded registry, which must be proof-bound + * @param chainId the chain id this registry is being bound to + * @return the pre-image bytes + */ + public static byte[] canonicalPreimageV2(final Registry registry, final long chainId) { + final java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream(); + writeAll(out, DOMAIN_V2.getBytes(StandardCharsets.US_ASCII)); + writeAll(out, uint64be(chainId)); + writeAll(out, uint64be(registry.bindHeight())); + writeAll(out, uint32be(registry.count())); + for (final Entry e : registry.entries) { + writeAll(out, uint32be(e.index)); + out.write(1); + writeAll(out, e.address); + writeAll(out, uint32be(e.publicKey.length)); + writeAll(out, e.publicKey); + writeAll(out, uint32be(e.possessionProof.length)); + writeAll(out, e.possessionProof); + writeAll(out, uint32be(e.claimProof.length)); + writeAll(out, e.claimProof); + } + return out.toByteArray(); + } + + /** + * D-146. keccak256 of the canonical v2 pre-image, as 64-hex with no {@code 0x}. + * + * @param registry the loaded registry, which must be proof-bound + * @param chainId the chain id this registry is bound to + * @return the canonical v2 hash, lower-case, unprefixed + */ + public static String hashV2(final Registry registry, final long chainId) { + return keccakHex(canonicalPreimageV2(registry, chainId)); + } + + /** + * D-146. The canonical hash OF THIS REGISTRY: v2 when it carries binding proofs, v1 when it does + * not. Every comparison against a schedule entry goes through here, so a proof-bound registry is + * compared as v2 everywhere and a legacy one keeps exactly the number it had before this change. + * + * @param registry the loaded registry + * @param chainId the chain id this registry is bound to + * @return the canonical hash for this registry's format + */ + public static String hashFor(final Registry registry, final long chainId) { + return registry.proofBound() ? hashV2(registry, chainId) : hashV1(registry, chainId); + } + + /** + * D-146. The ARMING precondition: refuse to arm the anchor over a registry whose rows are not + * bound to their validator addresses by signatures. + * + *

The shape is deliberately the same as {@code AERE-PQC-REG-ARM-01}, which already refuses to + * arm over a registry with no validator addresses at all, and for the same reason: arming is the + * last moment at which the registry format can still be changed. The anchor contract is immutable + * once written, so a fleet armed over unbound rows carries D-146 for the life of the chain. + * + *

WIRED 2026-08-06. It is called from {@code + * FalconSealSupport.requireRegistryBindingProofsOrAbort()}, in the constructor, immediately after + * {@code armingReadinessDiagnostic()} raises AERE-PQC-REG-ARM-01, and only when this node is armed + * (a configured {@code aere.falcon.forkBlock}, or an active certificate anchor). Until 2026-08-06 + * this javadoc read "NOT WIRED YET", and while that was true a v1 registry with no proofs loaded + * on an armed node exactly as before. + * + * @param registry the registry the fleet is about to arm over + * @throws RegistryConfigException when the registry carries no binding proofs + */ + public static void requireBindingsOrThrow(final Registry registry) { + if (registry != null && registry.proofBound()) { + return; + } + throw new RegistryConfigException( + "AERE-PQC-REG-ARM-02", + "AERE PQC D-146: REFUSING TO ARM (fail-closed) over the registry " + + (registry == null ? "(none)" : "'" + registry.sourcePath() + "'") + + ", which carries NO binding proofs. Nothing in such a registry connects a Falcon " + + "public key to the validator address on the same row, so whoever writes the file " + + "decides who a seal is credited to, and one key placed at two indices satisfies the " + + "quorum threshold on its own. Measured on the real verification path 2026-08-06: a " + + "registry with two rows' keys swapped, carrying no duplicate key and no duplicate " + + "address, produced an ACCEPTED header. WHAT TO DO: rebuild the registry in the v2 " + + "format, where every row carries a Falcon possession proof and an ECDSA claim signed " + + "by that validator's own consensus key, and set config.pqRegistryHash to its v2 hash."); + } + + /** + * keccak256 of the LEGACY pre-image, byte-for-byte what {@code FalconSealSupport.parseManifest} + * already computes: the concatenation, in ascending index order, of {@code address||publicKey} + * when address-bound and {@code publicKey} alone otherwise. + * + *

Kept so that a {@code pqRegistryHash} can be cross-checked against the manifest hash already + * committed in the genesis {@code alloc} anchor slot, and so an operator can tell which of the two + * numbers they are holding. It is NOT the value {@code pqRegistryHash} is compared against: it has + * no domain tag, no chain binding and no length prefixes. + * + * @param registry the loaded registry + * @return the legacy hash, lower-case, unprefixed + */ + public static String hashV0Legacy(final Registry registry) { + final java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream(); + for (final Entry e : registry.entries) { + if (registry.addressBound) { + writeAll(out, e.address); + } + writeAll(out, e.publicKey); + } + return keccakHex(out.toByteArray()); + } + + /** + * A short, safe fingerprint of one row: the first 8 hex of {@code keccak256(address||publicKey)}. + * + *

This is the field that turns a refusal into a five-second diagnosis. Two operators comparing + * refusal messages from two nodes see immediately WHICH index differs, without moving any key + * material and without either of them having to trust the other's file. + * + * @param registry the loaded registry + * @param index the row index + * @return an 8-hex fingerprint, or "(absent)" when the index is not present + */ + public static String fingerprint(final Registry registry, final int index) { + for (final Entry e : registry.entries) { + if (e.index == index) { + final java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream(); + if (e.address != null) { + writeAll(out, e.address); + } + writeAll(out, e.publicKey); + return keccakHex(out.toByteArray()).substring(0, 8); + } + } + return "(absent)"; + } + + // =================================================================================== + // Loaders. All STRICT, all fail-closed. + // =================================================================================== + + /** + * Load the LEGACY properties registry: {@code count=N} plus rows {@code i=}, and + * optionally {@code i.addr=<20-byte hex>} to make it address-bound. + * + *

Comments, blank lines, line order and surrounding whitespace do not affect the canonical + * hash: the hash is computed over the DECODED registry, not the file bytes. That is deliberate. + * Hashing the file bytes would make a node refuse to start because somebody added a comment, and a + * guard that fires on nothing real is a guard operators learn to switch off. + * + * @param path the properties file + * @return the loaded registry + * @throws RegistryConfigException on any ambiguity + */ + public static Registry loadPropertiesRegistry(final Path path) { + final Properties p = new Properties(); + try (InputStream in = Files.newInputStream(path)) { + p.load(in); + } catch (final IOException e) { + throw new RegistryConfigException( + "AERE-PQC-REG-LOAD-01", + "AERE PQC A8: cannot read the Falcon registry file '" + + path + + "': " + + e + + ". Refusing to continue (fail-closed): a node that cannot read its registry cannot " + + "verify a single Falcon seal, and starting it anyway would put an unverifying node " + + "into the validator set while it reports itself healthy."); + } + + final String countRaw = p.getProperty("count"); + if (countRaw == null) { + throw new RegistryConfigException( + "AERE-PQC-REG-LOAD-02", + "AERE PQC A8: the Falcon registry '" + + path + + "' has no 'count' property. Refusing to continue (fail-closed): without a declared " + + "count there is no way to tell a complete registry from one that lost its last " + + "lines in transit, and both would hash to something that looks valid. Add " + + "'count='."); + } + final int count = parsePositiveInt(countRaw, "count", path.toString()); + + // AERE D-146. Header fields of the v2 format. A v1 node reading a v2 file does NOT silently + // ignore these: parseIndexOrThrow refuses an unrecognised key, so an old binary handed a bound + // registry REFUSES rather than loading it with the proofs dropped. That is the correct + // direction of failure and it is why the fields are plain top-level names. + final long declaredChainId = parseOptionalLong(p.getProperty("chainId"), "chainId", path.toString()); + final long bindHeight = parseOptionalLong(p.getProperty("bindHeight"), "bindHeight", path.toString()); + final int formatVersion = + (int) parseOptionalLong(p.getProperty("formatVersion"), "formatVersion", path.toString()); + + final TreeMap pks = new TreeMap<>(); + final TreeMap addrs = new TreeMap<>(); + final TreeMap pops = new TreeMap<>(); + final TreeMap claims = new TreeMap<>(); + for (final String name : p.stringPropertyNames()) { + if ("count".equals(name) + || "chainId".equals(name) + || "bindHeight".equals(name) + || "formatVersion".equals(name)) { + continue; + } + final String key = name.trim(); + if (key.endsWith(".pop")) { + final int i = + parseIndexOrThrow(key.substring(0, key.length() - ".pop".length()), path.toString()); + pops.put(i, strictHex(p.getProperty(name).trim(), "entry " + i + ".pop", path.toString())); + } else if (key.endsWith(".claim")) { + final int i = + parseIndexOrThrow(key.substring(0, key.length() - ".claim".length()), path.toString()); + claims.put( + i, strictHex(p.getProperty(name).trim(), "entry " + i + ".claim", path.toString())); + } else if (key.endsWith(".addr")) { + final int i = + parseIndexOrThrow(key.substring(0, key.length() - ".addr".length()), path.toString()); + final byte[] a = strictHex(p.getProperty(name).trim(), "entry " + i + ".addr", path.toString()); + if (a.length != 20) { + throw new RegistryConfigException( + "AERE-PQC-REG-LOAD-03", + "AERE PQC A8: registry '" + + path + + "' entry " + + i + + ".addr is " + + a.length + + " bytes, expected exactly 20. Refusing to continue (fail-closed)."); + } + addrs.put(i, a); + } else { + final int i = parseIndexOrThrow(key, path.toString()); + pks.put(i, strictHex(p.getProperty(name).trim(), "entry " + i, path.toString())); + } + } + return assemble( + SourceKind.LEGACY_PROPERTIES, + path.toString(), + count, + pks, + addrs, + pops, + claims, + declaredChainId, + bindHeight, + formatVersion); + } + + /** + * Load an address-bound JSON manifest: {@code {"count": N, "0": {"addr": "0x..", "pk": ".."}, + * ...}}, or the legacy bare-string form {@code {"count": N, "0": "", ...}}. + * + * @param path the manifest file + * @return the loaded registry + * @throws RegistryConfigException on any ambiguity + */ + public static Registry loadManifestJson(final Path path) { + return fromManifestNode(readJson(path), SourceKind.MANIFEST_JSON, path.toString()); + } + + /** + * Load the manifest embedded in a genesis file at {@code config.aereFalconRegistry}. + * + * @param genesisPath the genesis file + * @return the loaded registry + * @throws RegistryConfigException on any ambiguity + */ + public static Registry loadGenesisManifest(final Path genesisPath) { + final JsonNode cfg = readJson(genesisPath).path("config").path("aereFalconRegistry"); + if (cfg.isMissingNode()) { + throw new RegistryConfigException( + "AERE-PQC-REG-LOAD-04", + "AERE PQC A8: genesis '" + + genesisPath + + "' has no config.aereFalconRegistry manifest. Refusing to continue (fail-closed)."); + } + return fromManifestNode(cfg, SourceKind.GENESIS_MANIFEST, genesisPath.toString()); + } + + /** + * Sniff the file and load it with the right loader: a genesis file if it has + * {@code config.aereFalconRegistry}, a bare manifest if it is JSON, otherwise properties. + * + * @param path any registry file + * @return the loaded registry + */ + public static Registry loadAuto(final Path path) { + final byte[] raw; + try { + raw = Files.readAllBytes(path); + } catch (final IOException e) { + throw new RegistryConfigException( + "AERE-PQC-REG-LOAD-01", + "AERE PQC A8: cannot read '" + path + "': " + e + ". Refusing to continue (fail-closed)."); + } + int i = 0; + while (i < raw.length && Character.isWhitespace((char) (raw[i] & 0xff))) { + i++; + } + if (i < raw.length && raw[i] == '{') { + final JsonNode root = readJson(path); + if (!root.path("config").path("aereFalconRegistry").isMissingNode()) { + return loadGenesisManifest(path); + } + return fromManifestNode(root, SourceKind.MANIFEST_JSON, path.toString()); + } + return loadPropertiesRegistry(path); + } + + private static Registry fromManifestNode( + final JsonNode cfg, final SourceKind kind, final String source) { + if (cfg == null || cfg.isMissingNode() || !cfg.has("count")) { + throw new RegistryConfigException( + "AERE-PQC-REG-LOAD-05", + "AERE PQC A8: manifest '" + source + "' has no 'count'. Refusing (fail-closed)."); + } + final int count = parsePositiveInt(cfg.get("count").asText(), "count", source); + + // Every numeric field present must be inside 0..count-1. + // + // FOUND BY THE PLANTED-FAILURE HARNESS, case L1, in the first run of this code: without this + // check a manifest that declares count=6 while carrying 7 rows loaded happily as a 6-row + // registry, because the loop below only ever asks for indices 0..count-1 and never looks at + // what else is in the file. That is precisely the failure this whole class exists to prevent - + // a registry that is quietly not the file the operator thinks it is - reintroduced one level + // down. Two nodes with the same file but different 'count' would have hashed differently. + final List strays = new ArrayList<>(); + final java.util.Iterator names = cfg.fieldNames(); + while (names.hasNext()) { + final String n = names.next(); + if ("count".equals(n) + || "chainId".equals(n) + || "bindHeight".equals(n) + || "formatVersion".equals(n)) { + continue; + } + final int idx; + try { + idx = Integer.parseInt(n.trim()); + } catch (final NumberFormatException e) { + throw new RegistryConfigException( + "AERE-PQC-REG-LOAD-18", + "AERE PQC A8: manifest '" + + source + + "' has the unrecognised field '" + + n + + "'. Every field must be 'count' or a validator index. Refusing (fail-closed)."); + } + if (idx < 0 || idx >= count) { + strays.add(n); + } + } + if (!strays.isEmpty()) { + throw new RegistryConfigException( + "AERE-PQC-REG-LOAD-09", + "AERE PQC A8: manifest '" + + source + + "' declares count=" + + count + + " but also carries entr" + + (strays.size() == 1 ? "y " : "ies ") + + strays + + ", outside 0.." + + (count - 1) + + ". Refusing (fail-closed): those rows would have been SILENTLY DROPPED and the " + + "remainder would still have hashed to a perfectly valid-looking value."); + } + + final long declaredChainId = + cfg.has("chainId") ? parseOptionalLong(cfg.get("chainId").asText(), "chainId", source) : -1L; + final long bindHeight = + cfg.has("bindHeight") + ? parseOptionalLong(cfg.get("bindHeight").asText(), "bindHeight", source) + : -1L; + final int formatVersion = + cfg.has("formatVersion") + ? (int) parseOptionalLong(cfg.get("formatVersion").asText(), "formatVersion", source) + : -1; + + final TreeMap pks = new TreeMap<>(); + final TreeMap addrs = new TreeMap<>(); + final TreeMap pops = new TreeMap<>(); + final TreeMap claims = new TreeMap<>(); + for (int i = 0; i < count; i++) { + final JsonNode e = cfg.get(Integer.toString(i)); + if (e == null) { + throw new RegistryConfigException( + "AERE-PQC-REG-LOAD-06", + "AERE PQC A8: manifest '" + + source + + "' declares count=" + + count + + " but has no entry '" + + i + + "'. Refusing (fail-closed)."); + } + if (e.isObject()) { + final JsonNode a = e.get("addr"); + final JsonNode k = e.get("pk"); + if (a == null || !a.isTextual() || k == null || !k.isTextual()) { + throw new RegistryConfigException( + "AERE-PQC-REG-LOAD-07", + "AERE PQC A8: manifest '" + + source + + "' entry " + + i + + " is an object but lacks textual 'addr' and 'pk'. Refusing (fail-closed)."); + } + final byte[] addr = strictHex(a.asText().trim(), "entry " + i + ".addr", source); + if (addr.length != 20) { + throw new RegistryConfigException( + "AERE-PQC-REG-LOAD-03", + "AERE PQC A8: manifest '" + + source + + "' entry " + + i + + " addr is " + + addr.length + + " bytes, expected 20. Refusing (fail-closed)."); + } + addrs.put(i, addr); + pks.put(i, strictHex(k.asText().trim(), "entry " + i + ".pk", source)); + final JsonNode pop = e.get("pop"); + final JsonNode claim = e.get("claim"); + if (pop != null && pop.isTextual()) { + pops.put(i, strictHex(pop.asText().trim(), "entry " + i + ".pop", source)); + } + if (claim != null && claim.isTextual()) { + claims.put(i, strictHex(claim.asText().trim(), "entry " + i + ".claim", source)); + } + } else if (e.isTextual()) { + pks.put(i, strictHex(e.asText().trim(), "entry " + i, source)); + } else { + throw new RegistryConfigException( + "AERE-PQC-REG-LOAD-08", + "AERE PQC A8: manifest '" + + source + + "' entry " + + i + + " is neither an object nor a hex string. Refusing (fail-closed)."); + } + } + return assemble( + kind, source, count, pks, addrs, pops, claims, declaredChainId, bindHeight, formatVersion); + } + + /** + * MEASURED 2026-08-02, on the first build of this class INSIDE Besu. These two parameters were + * declared {@code TreeMap}. Besu compiles with Error Prone's {@code NonApiType} check and {@code + * -Werror}, so a concrete collection type in a method signature is a build FAILURE, not a style + * note. This file had been type-checked standalone with {@code javac -Xlint:all} and reported + * clean; that is a weaker statement than it sounds, and the difference is the whole reason the + * wiring had to be compiled in the real tree before A8 could be called closed. {@code + * NavigableMap} keeps the guarantee the code actually relies on, which is ascending key order. + */ + private static Registry assemble( + final SourceKind kind, + final String source, + final int count, + final java.util.NavigableMap pks, + final java.util.NavigableMap addrs, + final java.util.NavigableMap pops, + final java.util.NavigableMap claims, + final long declaredChainId, + final long bindHeight, + final int declaredFormatVersion) { + + if (pks.size() != count) { + throw new RegistryConfigException( + "AERE-PQC-REG-LOAD-09", + "AERE PQC A8: registry '" + + source + + "' declares count=" + + count + + " but carries " + + pks.size() + + " public-key entr" + + (pks.size() == 1 ? "y" : "ies") + + " (indices present: " + + pks.keySet() + + "). Refusing (fail-closed): a registry whose declared size and real size disagree " + + "has no canonical form, so any hash of it would be meaningless."); + } + for (int i = 0; i < count; i++) { + if (!pks.containsKey(i)) { + throw new RegistryConfigException( + "AERE-PQC-REG-LOAD-10", + "AERE PQC A8: registry '" + + source + + "' is missing index " + + i + + "; indices must be exactly 0.." + + (count - 1) + + " with no gap. Indices present: " + + pks.keySet() + + ". Refusing (fail-closed)."); + } + if (pks.get(i).length == 0) { + throw new RegistryConfigException( + "AERE-PQC-REG-LOAD-11", + "AERE PQC A8: registry '" + source + "' index " + i + " has an EMPTY public key. " + + "Refusing (fail-closed)."); + } + } + final boolean bound; + if (addrs.isEmpty()) { + bound = false; + } else if (addrs.size() == count) { + bound = true; + for (int i = 0; i < count; i++) { + if (!addrs.containsKey(i)) { + throw new RegistryConfigException( + "AERE-PQC-REG-LOAD-12", + "AERE PQC A8: registry '" + + source + + "' binds addresses for " + + addrs.keySet() + + " but not for index " + + i + + ". Address binding must be all or nothing. Refusing (fail-closed)."); + } + } + } else { + throw new RegistryConfigException( + "AERE-PQC-REG-LOAD-12", + "AERE PQC A8: registry '" + + source + + "' is MIXED: " + + addrs.size() + + " of " + + count + + " entries carry a validator address. Address binding must be all or nothing, " + + "otherwise the eligible-signer set is not well defined. Refusing (fail-closed)."); + } + + // =============================================================================== + // AERE D-146 (2026-08-06). UNIQUENESS. Nothing here needs a signature, and it is the half that + // makes the THRESHOLD real again. + // + // MEASURED on the real verification path: a registry carrying ONE public key at TWO indices was + // loaded without a word, and two seals produced by a SINGLE Falcon private key - distinct + // signatures, because Falcon is randomised - were credited to two different validator addresses + // and satisfied a threshold of K=2. At K=3 one key holder alone would produce a certificate the + // chain calls a quorum. With distinct keys at every index, K verifying seals at K distinct + // indices are necessarily K distinct key holders again. + // + // Duplicate ADDRESSES are refused here too. Today they are already caught one layer up, by the + // `counted` set in PqAnchorSealsRule, but that is an availability trap rather than a defence: + // one address duplicated by mistake stops the chain instead of falsifying it, and it stops it at + // the activation height rather than at the moment the bad file was written. Refusing at load + // turns a fleet-wide halt into a node that will not start and says why. + final java.util.Map seenPk = new java.util.HashMap<>(); + for (int i = 0; i < count; i++) { + final String fp = keccakHex(pks.get(i)); + final Integer first = seenPk.putIfAbsent(fp, i); + if (first != null) { + throw new RegistryConfigException( + "AERE-PQC-REG-LOAD-19", + "AERE PQC D-146: registry '" + + source + + "' carries the SAME Falcon public key at index " + + first + + " and index " + + i + + " (key digest " + + fp.substring(0, 16) + + "). Refusing (fail-closed): the anchor threshold counts DISTINCT INDICES, so one " + + "key holder sitting at two indices produces two seals that satisfy a threshold of " + + "two on their own. The threshold would stop meaning 'K validators attested' and " + + "start meaning 'K rows attested', which is not a quorum of anything."); + } + } + if (bound) { + final java.util.Map seenAddr = new java.util.HashMap<>(); + for (int i = 0; i < count; i++) { + final String a = hex(addrs.get(i)); + final Integer first = seenAddr.putIfAbsent(a, i); + if (first != null) { + throw new RegistryConfigException( + "AERE-PQC-REG-LOAD-20", + "AERE PQC D-146: registry '" + + source + + "' binds the SAME validator address 0x" + + a + + " at index " + + first + + " and index " + + i + + ". Refusing (fail-closed): the seals rule counts each address once, so the " + + "second index can never contribute and the fleet silently loses a signer - " + + "measured as a REJECTED header at threshold K=2 with two honest signers."); + } + } + } + + // AERE D-146. FORMAT COHERENCE. Binding proofs are all-or-nothing, exactly like address binding, + // and for the same reason: one unproven row counts toward the threshold like a proven one. + final boolean anyProof = !pops.isEmpty() || !claims.isEmpty(); + final boolean proofBound; + if (anyProof || declaredFormatVersion == PqRegistryBinding.FORMAT_VERSION) { + if (pops.size() != count || claims.size() != count) { + throw new RegistryConfigException( + "AERE-PQC-REG-LOAD-21", + "AERE PQC D-146: registry '" + + source + + "' declares count=" + + count + + " but carries " + + pops.size() + + " possession proof(s) and " + + claims.size() + + " validator claim(s). Binding must be ALL OR NOTHING. Refusing (fail-closed): a " + + "registry that is bound for some rows and unbound for others has exactly the " + + "security of its unbound rows, while looking bound."); + } + if (declaredChainId < 0 || bindHeight < 0) { + throw new RegistryConfigException( + "AERE-PQC-REG-LOAD-22", + "AERE PQC D-146: registry '" + + source + + "' carries binding proofs but does not declare both 'chainId' and 'bindHeight'. " + + "Both are inside the signed message, so without them the proofs cannot even be " + + "checked, let alone trusted. Refusing (fail-closed)."); + } + proofBound = true; + } else { + proofBound = false; + } + + final List entries = new ArrayList<>(count); + for (int i = 0; i < count; i++) { + entries.add( + new Entry( + i, + bound ? addrs.get(i) : null, + pks.get(i), + proofBound ? pops.get(i) : null, + proofBound ? claims.get(i) : null)); + if (pks.get(i).length != FALCON_512_PK_LENGTH) { + LOG.warn( + "AERE PQC A8: registry '{}' index {} carries a {}-byte public key; Falcon-512 public " + + "keys are {} bytes. The registry is NOT rejected for this (the canonical form is " + + "length-prefixed and handles any length), but it is almost certainly the wrong " + + "file or a truncated copy.", + source, + i, + pks.get(i).length, + FALCON_512_PK_LENGTH); + } + } + final Registry assembled = + new Registry(kind, source, entries, bound, proofBound, declaredChainId, bindHeight); + // AERE D-146. THE VERIFICATION ITSELF, on the single path every loader funnels through, so it + // runs at every restart on every node and not only once at the ceremony. Fail-closed, and O(N) + // per process start with zero cost per block. + PqRegistryBinding.verifyOrThrow(assembled); + if (!proofBound) { + LOG.warn( + "AERE PQC D-146: registry '{}' ({} rows) carries NO binding proofs. Nothing in it " + + "connects a Falcon public key to the validator address on the same row, so whoever " + + "wrote this file decided who every seal is credited to. Measured 2026-08-06 on the " + + "real verification path: swapping two rows produces an ACCEPTED header with no " + + "duplicate key and no duplicate address. This is D-146 and it is not closed on this " + + "node.", + source, + count); + } + return assembled; + } + + // =================================================================================== + // The height-bound schedule + // =================================================================================== + + /** One scheduled binding: from {@code block} onwards the registry must hash to {@code hash}. */ + public static final class ScheduleEntry { + private final long block; + private final String hash; // 64-hex, lower-case, no 0x + + ScheduleEntry(final long block, final String hash) { + this.block = block; + this.hash = hash; + } + + /** + * First height at which this binding applies. + * + * @return the block number + */ + public long block() { + return block; + } + + /** + * The required canonical registry hash. + * + * @return 64-hex, lower-case, no 0x prefix + */ + public String hash() { + return hash; + } + + @Override + public String toString() { + return "{block=" + block + ", hash=0x" + hash + "}"; + } + } + + /** The parsed {@code config.pqRegistryHash} schedule; possibly empty, meaning NOT ENFORCED. */ + public static final class Schedule { + private final List entries; // strictly increasing block + private final String source; + + Schedule(final List entries, final String source) { + this.entries = entries; + this.source = source; + } + + /** + * Whether any binding is configured at all. + * + * @return true iff at least one schedule entry exists + */ + public boolean enforced() { + return !entries.isEmpty(); + } + + /** + * The schedule entries, ascending by block. + * + * @return an unmodifiable list + */ + public List entries() { + return List.copyOf(entries); + } + + /** + * Where the schedule was read from. + * + * @return a human-readable source description + */ + public String source() { + return source; + } + + @Override + public String toString() { + return entries.toString(); + } + } + + /** An empty schedule: nothing is enforced. Preserves today's behaviour exactly. */ + public static Schedule emptySchedule() { + return new Schedule(List.of(), "(none)"); + } + + /** + * Parse {@code config.pqRegistryHash} from a genesis file. + * + *

Two accepted shapes: + * + *

+   *   "pqRegistryHash": "0x<64 hex>"                       -- bound from block 0
+   *   "pqRegistryHash": [ {"block": H,  "hash": "0x.."},
+   *                       {"block": H2, "hash": "0x.."} ]   -- height-bound, with rotation
+   * 
+ * + *

ABSENT means NOT ENFORCED, which is the only choice that leaves the 11.8 million existing + * blocks and every existing test network untouched. Absence is logged loudly, once, because a + * guard everybody believes is on and is not is worse than no guard. + * + * @param genesisPath the genesis file + * @return the parsed schedule, possibly empty + * @throws RegistryConfigException when the value is present but malformed + */ + public static Schedule loadScheduleFromGenesis(final Path genesisPath) { + final JsonNode config = readJson(genesisPath).path("config"); + return parseSchedule(config.get("pqRegistryHash"), genesisPath + " config.pqRegistryHash"); + } + + /** + * Parse a {@code pqRegistryHash} JSON value into a schedule. + * + * @param node the value, or null when absent + * @param source human-readable provenance for diagnostics + * @return the schedule, empty when the node is null or JSON null + * @throws RegistryConfigException when present but malformed + */ + public static Schedule parseSchedule(final JsonNode node, final String source) { + if (node == null || node.isNull() || node.isMissingNode()) { + return new Schedule(List.of(), source + " (ABSENT)"); + } + final List out = new ArrayList<>(); + if (node.isTextual()) { + out.add(new ScheduleEntry(0L, normHash(node.asText().trim(), source))); + } else if (node.isArray()) { + if (node.isEmpty()) { + throw new RegistryConfigException( + "AERE-PQC-REG-SCHED-01", + "AERE PQC A8: " + + source + + " is an EMPTY array. Refusing to start (fail-closed): an empty schedule is not " + + "the same statement as an absent one, and guessing which was meant is exactly how " + + "a guard ends up switched off without anybody deciding to switch it off. Either " + + "remove the key entirely, or give it at least one {block, hash} entry."); + } + long previous = Long.MIN_VALUE; + for (final JsonNode e : node) { + if (!e.isObject() || !e.has("block") || !e.has("hash")) { + throw new RegistryConfigException( + "AERE-PQC-REG-SCHED-02", + "AERE PQC A8: " + + source + + " entry " + + e + + " is not an object with 'block' and 'hash'. Refusing to start (fail-closed)."); + } + final long block = e.get("block").asLong(-1L); + if (block < 0) { + throw new RegistryConfigException( + "AERE-PQC-REG-SCHED-03", + "AERE PQC A8: " + + source + + " has a negative or unparseable block in " + + e + + ". Refusing to start (fail-closed)."); + } + if (block <= previous) { + throw new RegistryConfigException( + "AERE-PQC-REG-SCHED-04", + "AERE PQC A8: " + + source + + " blocks are not STRICTLY INCREASING (" + + previous + + " then " + + block + + "). Refusing to start (fail-closed): with a repeated or out-of-order height " + + "two nodes reading the same file could pick different active entries, which is " + + "the very split this binding exists to prevent."); + } + previous = block; + out.add(new ScheduleEntry(block, normHash(e.get("hash").asText(), source))); + } + } else { + throw new RegistryConfigException( + "AERE-PQC-REG-SCHED-05", + "AERE PQC A8: " + + source + + " must be a 0x-prefixed 32-byte hash or an array of {block, hash}; found " + + node.getNodeType() + + ". Refusing to start (fail-closed)."); + } + return new Schedule(out, source); + } + + /** + * The registry hash required at a given height, or empty when no binding is active there. + * + *

Public because the startup guard is not the only place this belongs: a per-block header rule + * enforcing the same binding on the consensus path would call exactly this. + * + * @param schedule the parsed schedule + * @param blockNumber the height + * @return the active entry, or empty + */ + public static Optional requiredHashAt( + final Schedule schedule, final long blockNumber) { + ScheduleEntry active = null; + for (final ScheduleEntry e : schedule.entries) { + if (e.block <= blockNumber) { + active = e; + } else { + break; + } + } + return Optional.ofNullable(active); + } + + /** The first scheduled entry strictly after the given height, or empty. */ + private static Optional nextAfter(final Schedule schedule, final long blockNumber) { + for (final ScheduleEntry e : schedule.entries) { + if (e.block > blockNumber) { + return Optional.of(e); + } + } + return Optional.empty(); + } + + /** + * Whether the given registry satisfies the binding active at a height. Never throws. + * + * @param schedule the parsed schedule + * @param registry the loaded registry, may be null + * @param blockNumber the height + * @param chainId the chain id + * @return true iff no binding is active there, or the registry hashes to the required value + */ + public static boolean matchesAt( + final Schedule schedule, + final Registry registry, + final long blockNumber, + final long chainId) { + final Optional req = requiredHashAt(schedule, blockNumber); + if (req.isEmpty()) { + return true; + } + if (registry == null) { + return false; + } + return hashFor(registry, chainId).equalsIgnoreCase(req.get().hash); + } + + // =================================================================================== + // D-081: the whole scheduled history, not only the entry in force at the head + // =================================================================================== + + /** + * D-081. The registries a node holds, indexed by the SCHEDULE ENTRY each one satisfies. + * + *

THE DEFECT THIS EXISTS TO CLOSE, measured and not assumed. {@code config.pqRegistryHash} is a + * schedule, and a second entry is how a key rotation or a revocation is expressed. Enforcement, + * however, took exactly ONE loaded registry: {@link #matchesAt(Schedule, Registry, long, long)} + * resolves the entry active at a height and compares it against that single registry. After one + * rotation at H2 there are two intervals with two different required hashes, and one file can + * satisfy at most one of them. Measured on the real classes in {@code PqRegistryRotationTest}: + * a node holding the pre-rotation registry was refused at {12100000, 12100001}, a node holding the + * post-rotation registry was refused at {12000000, 12000001, 12099999}, and there was no third + * choice. + * + *

That is not confined to the rotation moment. {@code PqRegistryBindingRule} is DETACHED, so it + * runs on the header-download path, and {@code PqAnchorSyncModeGuard} refuses to start an armed + * node in anything but FULL sync. Every node acquiring history therefore validates the interval + * before the rotation AND has to reach the head. Using the rotation mechanism once made the chain + * permanently unjoinable, which means the mechanism was expressible and was not usable. + * + *

THE SHAPE OF THE REPAIR, and where it was learned. Cosmos ADR-016 keeps a rotation HISTORY + * mapping height to the consensus key in force there, precisely so that blocks signed under an + * older key stay verifiable, and bounds that history by the unbonding period. We keep the same + * mapping and we do NOT bound it: chain 2800 has no unbonding period and a node syncing from + * genesis must verify every block ever produced, so every entry ever scheduled stays loadable + * forever. Besu's own answer to the same class of question, the genesis {@code transitions} list, + * has the same shape: the value in force at a height is fixed by the height, lives in a document + * every node already holds byte-identically, and is never re-read from mutable state. + * + *

A set that does not cover every scheduled entry is NOT silently degraded. {@link + * #uncoveredEntryBlocks()} names exactly which heights have no registry, so the startup guard can + * refuse with the missing heights in hand instead of the operator discovering them one header at + * a time. + */ + public static final class RegistrySet { + private final List loaded; + private final List entryBlocks; // ascending, one per COVERED schedule entry + private final List entryRegistries; // parallel to entryBlocks + private final List uncovered; // schedule entry blocks with no matching registry + private final List misbound; // D-B: hash matched, signed height did not + + RegistrySet( + final List loaded, + final List entryBlocks, + final List entryRegistries, + final List uncovered) { + this(loaded, entryBlocks, entryRegistries, uncovered, List.of()); + } + + RegistrySet( + final List loaded, + final List entryBlocks, + final List entryRegistries, + final List uncovered, + final List misbound) { + this.loaded = loaded; + this.entryBlocks = entryBlocks; + this.entryRegistries = entryRegistries; + this.uncovered = uncovered; + this.misbound = misbound; + } + + /** + * Every registry this node was given, in the order it was given them. + * + * @return an unmodifiable list + */ + public List loaded() { + return List.copyOf(loaded); + } + + /** + * The registry this node SIGNS with, i.e. the first one it was given. Every other held file is + * history: loadable so that old blocks stay verifiable, never the one this node writes under. + * + * @return the primary registry, or null when this node holds none + */ + public Registry primary() { + return loaded.isEmpty() ? null : loaded.get(0); + } + + /** + * D-B. The schedule entries a held registry reproduces BY HASH and yet was not signed for. + * + * @return an unmodifiable list, empty when every covered entry is coherently signed + */ + public List misbound() { + return List.copyOf(misbound); + } + + /** + * The scheduled heights this node CAN answer for. + * + * @return an unmodifiable ascending list + */ + public List coveredEntryBlocks() { + return List.copyOf(entryBlocks); + } + + /** + * How many registries this node holds. + * + * @return the count + */ + public int count() { + return loaded.size(); + } + + /** + * The scheduled heights for which this node holds NO registry at all. + * + * @return an unmodifiable ascending list, empty when the whole schedule is covered + */ + public List uncoveredEntryBlocks() { + return List.copyOf(uncovered); + } + + /** + * Whether this node can satisfy every entry the schedule contains. + * + * @return true iff nothing is uncovered + */ + public boolean coversWholeSchedule() { + return uncovered.isEmpty(); + } + + /** The registry bound to a schedule entry, or null when that entry is uncovered. */ + Registry forEntryBlock(final long entryBlock) { + for (int i = 0; i < entryBlocks.size(); i++) { + if (entryBlocks.get(i).longValue() == entryBlock) { + return entryRegistries.get(i); + } + } + return null; + } + + @Override + public String toString() { + return "RegistrySet{held=" + + loaded.size() + + ", covered=" + + entryBlocks + + ", uncovered=" + + uncovered + + '}'; + } + } + + /** + * D-081. Bind a set of loaded registries to the schedule by canonical hash. + * + *

The binding is by HASH and never by order or by file name: a registry covers the entry whose + * required hash it reproduces, and nothing else makes it cover anything. An operator therefore + * cannot mis-assign a file, only fail to supply one, and that failure is named. + * + * @param schedule the parsed schedule + * @param registries every registry this node holds; nulls are ignored + * @param chainId the chain id, part of the canonical pre-image + * @return the height-resolved set + */ + public static RegistrySet buildSet( + final Schedule schedule, final List registries, final long chainId) { + final List held = new ArrayList<>(); + for (final Registry r : registries) { + if (r != null) { + held.add(r); + } + } + final List blocks = new ArrayList<>(); + final List mapped = new ArrayList<>(); + final List uncovered = new ArrayList<>(); + final List misbound = new ArrayList<>(); + for (final ScheduleEntry e : schedule.entries) { + Registry match = null; + for (final Registry r : held) { + if (!hashFor(r, chainId).equalsIgnoreCase(e.hash)) { + continue; + } + // AERE D-B (2026-08-06). THE LINK THAT WAS NEVER DRAWN. Both numbers have been in this + // lexical scope since D-081 was written and they were never put on the same expression. + // + // bindHeight is the height every row's possession proof and every row's validator claim + // were SIGNED OVER (PqRegistryBinding.bindingPreimage). e.block is the height from which + // genesis puts this registry IN FORCE. They are supposed to be the same number, and the + // hash cannot notice when they are not: bindHeight is INSIDE the v2 pre-image, so a file + // signed for 40 and scheduled from 70 reproduces its own hash perfectly. Measured + // 2026-08-06 on a network of seven: all 7 started, the chain ran to head 95, agreement was + // 7 of 7, zero errors and zero refusals - and the offline tool, on the same files, said RED. + // The node and the tool disagreed and nothing put them face to face. + // + // WHAT THAT BUYS AN OPERATOR WHO IS NOT SUPPOSED TO HAVE IT: moving the activation day + // costs two fresh signatures per validator if this is checked, and ZERO if it is not. + // The validators' agreement on a height is only an agreement if something refuses the + // heights they did not sign. + if (r.proofBound() && r.bindHeight() != e.block) { + misbound.add(new Misbound(e.block, e.hash, r.sourcePath(), r.bindHeight())); + continue; + } + match = r; + break; + } + if (match == null) { + uncovered.add(e.block); + } else { + blocks.add(e.block); + mapped.add(match); + } + } + return new RegistrySet(held, blocks, mapped, uncovered, misbound); + } + + /** + * D-B. One schedule entry whose required hash a held registry reproduces, and whose height that + * registry's binding proofs were not signed over. + * + * @param entryBlock the height genesis puts the registry in force from + * @param entryHash the hash genesis requires there + * @param registryPath the file that reproduces that hash + * @param signedHeight the height the file's proofs were actually signed over + */ + public record Misbound( + long entryBlock, String entryHash, String registryPath, long signedHeight) {} + + /** + * D-081. Load every registry named and bind the result to the schedule. + * + * @param schedule the parsed schedule + * @param paths the registry files this node holds + * @param chainId the chain id + * @return the height-resolved set + * @throws RegistryConfigException when any file is unreadable or ambiguous + */ + public static RegistrySet loadSet( + final Schedule schedule, final List paths, final long chainId) { + final List registries = new ArrayList<>(); + for (final Path p : paths) { + registries.add(loadAuto(p)); + } + return buildSet(schedule, registries, chainId); + } + + /** + * D-081. Split a comma-separated list of registry paths. Blank elements are dropped; a null or + * blank list gives an empty result rather than a path named "". + * + * @param raw the configured value, may be null + * @return the paths, in the order written + */ + public static List parseRegistryPaths(final String raw) { + final List out = new ArrayList<>(); + if (raw == null || raw.isBlank()) { + return out; + } + for (final String piece : Splitter.on(',').split(raw)) { + final String trimmed = piece.trim(); + if (!trimmed.isEmpty()) { + out.add(Path.of(trimmed)); + } + } + return out; + } + + /** + * D-081. The registry in force at a height: the one bound to the schedule entry active there. + * + *

This is the function Falcon verification needs. A certificate in a block at height h was + * produced under the key set the chain required at h, so it must be checked against that key set + * and not against whatever the node happens to hold for the head. + * + * @param schedule the parsed schedule + * @param set the registries this node holds, may be null + * @param blockNumber the height + * @return the registry in force there, or empty when no binding is active or none is held + */ + public static Optional registryAt( + final Schedule schedule, final RegistrySet set, final long blockNumber) { + final Optional required = requiredHashAt(schedule, blockNumber); + if (required.isEmpty() || set == null) { + return Optional.empty(); + } + return Optional.ofNullable(set.forEntryBlock(required.get().block)); + } + + /** + * D-081. Whether the registries this node holds satisfy the binding active at a height. + * + *

The hash is RECOMPUTED here rather than trusted from the binding built earlier, so that this + * answer is a positive proof about the bytes the node is holding right now and not a restatement + * of an earlier decision. + * + * @param schedule the parsed schedule + * @param set the registries this node holds, may be null + * @param blockNumber the height + * @param chainId the chain id + * @return true iff no binding is active there, or a held registry reproduces the required hash + */ + public static boolean matchesAt( + final Schedule schedule, + final RegistrySet set, + final long blockNumber, + final long chainId) { + final Optional required = requiredHashAt(schedule, blockNumber); + if (required.isEmpty()) { + return true; + } + if (set == null) { + return false; + } + final Registry r = set.forEntryBlock(required.get().block); + return r != null && hashFor(r, chainId).equalsIgnoreCase(required.get().hash); + } + + // =================================================================================== + // The startup guard + // =================================================================================== + + /** Outcome of the startup guard. Exposed so tests and diagnostics can assert on it. */ + public enum GateState { + /** No {@code pqRegistryHash} in genesis: nothing is enforced, exactly as before this change. */ + NOT_ENFORCED_NO_SCHEDULE, + /** A schedule exists but its first height is still ahead of this node's chain head. */ + NOT_ENFORCED_BELOW_FIRST_HEIGHT, + /** A binding is active at this height and the loaded registry satisfies it. */ + MATCH, + /** + * AERE OPTIUNI-URGENTA: a binding is active, the registry does NOT satisfy it, and an operator + * explicitly overrode the refusal. The node is running and is NOT verifying certificates. + */ + OVERRIDDEN_UNSAFE + } + + /** + * Refusal to start, or refusal to accept a registry. Carries a stable machine-readable code so + * fleet tooling can match on it without parsing prose. + */ + public static final class RegistryConfigException extends IllegalStateException { + + private static final long serialVersionUID = 1L; + + private final String code; + + RegistryConfigException(final String code, final String message) { + super("[" + code + "] " + message); + this.code = code; + } + + /** + * The stable code, e.g. {@code AERE-PQC-REG-MISMATCH-01}. + * + * @return the code + */ + public String code() { + return code; + } + } + + /** + * THE GUARD. Verify the registry this node actually loaded against the hash the genesis schedule + * makes active at the NEXT height this node will be asked to validate, chainHead + 1, and REFUSE + * TO START on a mismatch. + * + *

AERE CONSENS-LENS (2026-08-02): chainHead + 1, and not chainHead. That one block of + * difference was a permanent chain stop, measured on real nodes; the measurement and the + * reasoning are on the line that computes the height. + * + *

Call once during startup, from a point where a chain head exists and BEFORE the network and + * the consensus state machine are started, so that a refusal is a clean refusal to start rather + * than a mid-flight abort. The same place {@code verifyAttachHeightAgainstChainHeadOrAbort} is + * called from is correct. + * + * @param schedule the parsed {@code config.pqRegistryHash} schedule + * @param registry the registry this node loaded, or null if it loaded none + * @param chainHeadNumber this node's local chain head at startup + * @param chainId the chain id, which is part of the canonical pre-image + * @return the gate state on success + * @throws RegistryConfigException when a binding is active and the registry does not satisfy it + */ + public static GateState verifyOrAbort( + final Schedule schedule, + final Registry registry, + final long chainHeadNumber, + final long chainId) { + return verifyOrAbort( + schedule, + buildSet(schedule, registry == null ? List.of() : List.of(registry), chainId), + chainHeadNumber, + chainId); + } + + /** + * AERE D-A (2026-08-06). THE SAME GUARD, ASKED OF EVERY REGISTRY THIS NODE HOLDS. + * + *

WHY THIS OVERLOAD HAD TO EXIST, and it is not tidiness. D-081 gave a node a HISTORY of + * registry files, one per rotation the chain has ever performed, precisely so that a node can + * validate blocks produced under a retired key set. The startup guard was never told. It compared + * the schedule against ONE registry, the one named by {@code registrySourcePath}, and the history + * was read 41 lines further down - after the refusal had already been thrown. So the order of two + * blocks of code WAS the defect: after the first rotation, a node whose primary file is the + * post-rotation registry and whose history holds the pre-rotation one is correctly configured, and + * was refused to start with {@code AERE-PQC-REG-MISMATCH-01}. Re-confirmed 2026-08-06 with head at + * 2554. The consequence, stated plainly: after the first key rotation, no node in the fleet could + * be restarted with its own correct configuration without hand intervention. + * + *

The question this asks is unchanged - "is the binding the chain requires at the next height I + * will validate satisfied by something I hold" - only the word "something" is now honest. + * + * @param schedule the parsed {@code config.pqRegistryHash} schedule + * @param set every registry this node holds, resolved against the schedule + * @param chainHeadNumber this node's local chain head at startup + * @param chainId the chain id, which is part of the canonical pre-image + * @return the gate state on success + * @throws RegistryConfigException when a binding is active and nothing held satisfies it + */ + public static GateState verifyOrAbort( + final Schedule schedule, + final RegistrySet set, + final long chainHeadNumber, + final long chainId) { + + // AERE D-146. The proofs are signed over a chainId the FILE declares. A registry lifted from + // the scratch chain 442807 carries proofs that verify perfectly among themselves - they are + // internally consistent, just for another chain - and would otherwise pass. Fail-closed here, + // where the node's real chain id is known and the file's is not yet trusted. + // + // AERE D-A (2026-08-06): over EVERY held file, not only the primary. A history file lifted from + // the scratch chain is exactly as dangerous as a primary one - it is the file that answers for + // an interval of history - and before this it was never asked. + for (final Registry r : set.loaded()) { + if (r.proofBound() && r.declaredChainId() != chainId) { + throw new RegistryConfigException( + "AERE-PQC-REG-BIND-07", + "AERE PQC D-146: registry '" + + r.sourcePath() + + "' declares chainId=" + + r.declaredChainId() + + " and its binding proofs are signed over that value, but this node is on chainId=" + + chainId + + ". Refusing to start (fail-closed): the proofs in this file are valid, and they " + + "are valid for a different chain."); + } + } + + final Registry registry = set.primary(); + + if (!schedule.enforced()) { + LOG.warn( + "AERE PQC A8: pqRegistryHash is NOT CONFIGURED ({}), so the Falcon registry is NOT bound " + + "to consensus on this node. The registry currently loaded is {} ({}, {} entries, " + + "address-bound={}), canonical hash 0x{}. Two nodes holding DIFFERENT registry files " + + "will disagree about which public key validator index i has, so the same " + + "certificate can verify on one and fail on the other. Put that hash into genesis as " + + "config.pqRegistryHash to close this.", + schedule.source(), + registry == null ? "(none)" : registry.sourcePath(), + registry == null ? SourceKind.NONE : registry.kind(), + registry == null ? 0 : registry.count(), + registry != null && registry.addressBound(), + // A8, measured on a fleet of seven on 2026-08-06: this printed hashV1 next to the text + // "put this in config.pqRegistryHash", while THE GATE compares hashFor, which for a + // registry carrying proofs is hashV2. With the printed value put into genesis, all seven + // nodes start, all seven report the registry loaded, and THE CHAIN STOPS AT H-1. The + // guard shouts NOT CORRECTLY STAGED, so it is not a silent halt, but the operator who + // follows the node's own instruction halts the fleet. A wrong instruction is more + // dangerous than no instruction at all. + registry == null ? "(no registry)" : hashFor(registry, chainId)); + return GateState.NOT_ENFORCED_NO_SCHEDULE; + } + + // AERE D-B (2026-08-06). THE SILENT DEFERRAL. Placed HERE, below the not-enforced exit above, + // and that position is a rule and not a preference: on chain 2800 config.pqRegistryHash does not + // exist, schedule.enforced() is false, and the return above is the first executable statement + // this guard reaches. Nothing new is ever put above it. + // + // WHY REFUSING IS RIGHT AND NOT MERELY SAFE. A misbound entry is not a missing file, which an + // operator can legitimately have; it is a file the operator DOES hold, which reproduces exactly + // the hash genesis names, and which the fleet signed for a DIFFERENT height. There is no honest + // reading of it. Refusing at startup also catches it before the height arrives, which is the + // whole point: an activation day moved under signed proofs is invisible until the day itself. + if (!set.misbound().isEmpty()) { + final StringBuilder rows = new StringBuilder(); + for (final Misbound m : set.misbound()) { + rows.append(" genesis puts registry '") + .append(m.registryPath()) + .append("' in force FROM BLOCK ") + .append(m.entryBlock()) + .append(", and every binding proof in that file is signed over bindHeight=") + .append(m.signedHeight()) + .append(" (entry hash 0x") + .append(m.entryHash()) + .append(")\n"); + } + throw new RegistryConfigException( + "AERE-PQC-REG-BIND-08", + "AERE PQC D-B: REFUSING TO START - a registry this node holds is scheduled at a height " + + "its validators never signed for.\n" + + " FIELD: 'bindHeight', inside the registry file, versus 'block' of the matching " + + "entry of config.pqRegistryHash in genesis (" + + schedule.source() + + ").\n" + + " READ:\n" + + rows + + " EXPECTED: bindHeight and the schedule entry's block are THE SAME NUMBER. Every " + + "row of a v2 registry carries a Falcon possession proof and an ECDSA claim by that " + + "validator's own consensus key, and both signatures cover the bind height. A " + + "registry in force from block B is every one of those validators stating that B is " + + "the height they agreed to.\n" + + " WHY THE HASH DID NOT CATCH THIS: bindHeight is INSIDE the v2 pre-image, so the " + + "file reproduces its own hash perfectly no matter which height genesis schedules it " + + "at. Hash-against-hash can never see this; only these two numbers side by side can. " + + "Measured 2026-08-06 on a network of seven: all seven started, the chain ran, " + + "agreement was 7 of 7, zero errors, zero refusals.\n" + + " WHAT IT WOULD HAVE COST AN ATTACKER OR A HURRIED OPERATOR: moving the activation " + + "day costs 14 fresh validator signatures when this is checked, and zero when it is " + + "not.\n" + + " REMEDY, and there are exactly two honest ones. EITHER put the schedule entry back " + + "to the height the file was signed for, on every node, because genesis is the " + + "document all seven hold byte-identically. OR re-run the key ceremony and have every " + + "validator re-sign its row for the new height, then set config.pqRegistryHash to the " + + "NEW hash of the re-signed file - it will be a different hash, because the height is " + + "inside it.\n" + + " DO NOT edit bindHeight in the file. The proofs are over it; the file would stop " + + "loading at all (AERE-PQC-REG-BIND-03)."); + } + + // AERE CONSENS-LENS (2026-08-02). THE HEIGHT THIS GUARD ASKS ABOUT IS chainHead + 1, and the + // one-block difference was a PERMANENT CHAIN STOP, measured on real nodes before this line was + // changed. With a schedule of two entries and a rotation at height X: + // - every node runs to X-1 and REFUSES header X (PqRegistryBindingRule, + // AERE-PQC-REG-BLOCK-01). Measured: head stopped at 11 with a rotation at 12. + // - the operator installs the registry the schedule requires FROM X and restarts. This guard, + // asked about the HEAD X-1, demanded the OLD hash and refused to start with + // AERE-PQC-REG-MISMATCH-01, exit 2. Measured. + // - the operator puts the OLD registry back: the node starts and still cannot pass X. + // Measured. + // Old registry: starts, cannot advance. New registry: cannot start. There was no third file, + // and the only exit measured was to set the emergency bypass on every node, i.e. to switch A8 + // off across the whole fleet in order to cross a PLANNED rotation. + // + // chainHead + 1 is the question the node can actually act on: the only header it will be + // offered next is chainHead + 1, and PqRegistryBindingRule judges that header against the entry + // active AT THAT HEIGHT. Asking the same question here means the startup guard and the + // per-block rule can no longer disagree, which is the property that was missing. A node halted + // at X-1 now starts with the registry required from X. A node staged EARLY is refused at + // startup, and that is the honest verdict: it could not validate the blocks between its head + // and the rotation, and refusing at startup is better than stalling on the first header. + final long validationHeight = + chainHeadNumber == Long.MAX_VALUE ? chainHeadNumber : chainHeadNumber + 1; + final Optional active = requiredHashAt(schedule, validationHeight); + + if (active.isEmpty()) { + final ScheduleEntry first = schedule.entries.get(0); + // AERE D-A: ask the SET, not only the primary. A node staged for the activation may already + // hold the activation registry as history while still signing under the current one. + final Registry staged = set.forEntryBlock(first.block()); + final String computed = registry == null ? null : hashFor(registry, chainId); + if (staged != null) { + LOG.info( + "AERE PQC A8: registry binding is scheduled to start at block {} and this node's chain " + + "head is {}, so nothing is enforced yet. The registry already loaded ({}, {} " + + "entries) ALREADY MATCHES the hash required from block {}: 0x{}. This node is " + + "correctly staged for the activation.", + first.block(), + chainHeadNumber, + staged.sourcePath(), + staged.count(), + first.block(), + hashFor(staged, chainId)); + } else { + LOG.error( + "AERE PQC A8: registry binding starts at block {} and this node's chain head is {}, so " + + "nothing is enforced yet AND THIS NODE IS NOT CORRECTLY STAGED. Required from " + + "block {}: 0x{}. Loaded here: {}. This node will run normally and will then " + + "REFUSE the header at block {} (AERE-PQC-REG-BLOCK-01) and stop there. Install " + + "the registry named above AT THAT HALT and restart. Do NOT install it earlier: " + + "from the moment it is installed this node can no longer validate the blocks " + + "between here and the rotation, and it will be refused at startup. NOTE the " + + "honest limitation: this guard runs at STARTUP only, so it will NOT stop this " + + "node while it keeps running.", + first.block(), + chainHeadNumber, + first.block(), + first.hash(), + computed == null ? "NO REGISTRY LOADED AT ALL" : "0x" + computed, + first.block()); + } + return GateState.NOT_ENFORCED_BELOW_FIRST_HEIGHT; + } + + final ScheduleEntry required = active.get(); + + if (set.count() == 0) { + throw new RegistryConfigException( + "AERE-PQC-REG-MISMATCH-02", + "AERE PQC A8: REFUSING TO START.\n" + + " EXPECTED: a Falcon validator registry whose canonical hash is\n" + + " 0x" + + required.hash() + + "\n" + + " required from block " + + required.block() + + " by genesis config.pqRegistryHash (" + + schedule.source() + + ").\n" + + " FOUND: NO REGISTRY AT ALL. None of the three registry sources is configured.\n" + + " Searched, in this order:\n" + + " 1) aere.falcon.genesis / AERE_FALCON_GENESIS (genesis manifest)\n" + + " 2) aere.falcon.manifest / AERE_FALCON_MANIFEST (late-anchor)\n" + + " 3) aere.falcon.registry / AERE_FALCON_REGISTRY (legacy properties)\n" + + " CHAIN HEAD: " + + chainHeadNumber + + " NEXT HEIGHT TO VALIDATE: " + + validationHeight + + " (the binding above is the one active AT THAT HEIGHT)\n" + + " CHAIN ID: " + + chainId + + "\n" + + " WHY THIS IS FATAL: from block " + + required.block() + + " this chain states which registry every node must hold. A node with no registry " + + "verifies no Falcon seal at all, so it would sit in the validator set reporting " + + "itself healthy while contributing nothing to the post-quantum layer.\n" + + " REMEDY: point one of the three properties above at the registry file whose hash " + + "is 0x" + + required.hash() + + ", then restart. Compute a file's hash with:\n" + + " java -cp " + + "org.hyperledger.besu.consensus.common.bft.tools.PqRegistryHashTool " + + " --chain-id " + + chainId); + } + + final String computed = hashFor(registry, chainId); + // AERE D-A (2026-08-06): the binding is satisfied by ANY file this node holds for this entry, + // not only by the one it signs with. Before this line the answer came from the primary registry + // alone, and after the first rotation the primary is by definition NOT the file that answers for + // the interval below the rotation. + final Registry bound = set.forEntryBlock(required.block()); + if (bound != null) { + LOG.info( + "AERE PQC A8: registry binding SATISFIED. Loaded {} ({}, {} entries, address-bound={}); " + + "canonical hash 0x{} equals the hash required from block {} by genesis " + + "config.pqRegistryHash, read from [{}]. Chain head {}, chainId {}. This node holds " + + "{} registry file(s) in total. {}", + bound.sourcePath(), + bound.kind(), + bound.count(), + bound.addressBound(), + hashFor(bound, chainId), + required.block(), + // AERE A8: naming the PROVENANCE of the schedule is not decoration. The whole defect + // class is "a value that came from somewhere nobody checked", so a line that says the + // binding is satisfied without saying what it was read from asserts more than it knows. + schedule.source(), + chainHeadNumber, + chainId, + set.count(), + nextAfter(schedule, validationHeight) + .map( + n -> + "NEXT ROTATION: from block " + + n.block() + + " this node must hold a registry hashing to 0x" + + n.hash() + + (set.forEntryBlock(n.block()) != null + ? " - already satisfied by a file this node holds." + : " - NOTHING this node holds satisfies it. This node will run" + + " to block " + + (n.block() - 1) + + ", REFUSE the header at " + + n.block() + + " (AERE-PQC-REG-BLOCK-01) and stop there. Install the new file" + + " and name it in " + + "aere.falcon.registry.history, then restart.")) + .orElse("No further rotation is scheduled.")); + return GateState.MATCH; + } + + throw new RegistryConfigException( + "AERE-PQC-REG-MISMATCH-01", + "AERE PQC A8: REFUSING TO START - the Falcon validator registry on this node is NOT the one " + + "this chain requires.\n" + + " EXPECTED hash: 0x" + + required.hash() + + "\n" + + " required from block " + + required.block() + + " by genesis config.pqRegistryHash (" + + schedule.source() + + ")\n" + + " FOUND hash: 0x" + + computed + + "\n" + + " CHAIN HEAD: " + + chainHeadNumber + + " NEXT HEIGHT TO VALIDATE: " + + validationHeight + + " (the binding above is the one active AT THAT HEIGHT)\n" + + " CHAIN ID: " + + chainId + + " (chainId is part of the hashed pre-image)\n" + + " FULL SCHEDULE: " + + schedule + + "\n" + + describeHeld(set, chainId) + + describeRegistry(registry, chainId) + + " WHY THIS IS FATAL: this file decides which Falcon public key belongs to validator " + + "index i. A node holding a different file disagrees with its peers about who index i " + + "is, so the SAME certificate verifies on one node and fails on another and the " + + "network splits with no attacker involved. Starting with the wrong file is strictly " + + "worse than not starting.\n" + + " HOW TO FIND THE WRONG ROW: every node prints the per-index fingerprints above. " + + "Compare them with a node that starts cleanly; the first index that differs is the " + + "row that was changed. The fingerprints are keccak digests, so they can be pasted " + + "into a chat without moving key material.\n" + + " REMEDY: this node must HOLD a file whose canonical hash is 0x" + + required.hash() + + ". It does not have to be the file this node signs with: name it in " + + "aere.falcon.registry.history (comma-separated) and every file listed there answers " + + "for the interval the schedule gives it. That list is never pruned - one file per " + + "rotation the chain has ever performed, kept forever. Then restart. To check a " + + "candidate file before restarting:\n" + + " java -cp " + + "org.hyperledger.besu.consensus.common.bft.tools.PqRegistryHashTool " + + " --chain-id " + + chainId + + "\n" + + " DO NOT 'fix' this by editing genesis. The genesis value is what the rest of the " + + "fleet agrees on; changing it on this node alone makes this node the odd one out."); + } + + /** + * AERE D-A. Every registry file this node holds and which scheduled interval each one answers for. + * Without this an operator reading {@code MISMATCH-01} cannot tell "I gave this node one file and + * it is the wrong one" from "I gave it four and none covers this height", which are different + * mistakes with different fixes. + */ + private static String describeHeld(final RegistrySet set, final long chainId) { + final StringBuilder b = new StringBuilder(); + b.append(" REGISTRY FILES THIS NODE HOLDS: ").append(set.count()).append('\n'); + for (final Registry r : set.loaded()) { + final String h = hashFor(r, chainId); + Long covers = null; + for (final Long blk : set.coveredEntryBlocks()) { + if (set.forEntryBlock(blk) == r) { + covers = blk; + break; + } + } + b.append(" ") + .append(r.sourcePath()) + .append(" hash=0x") + .append(h) + .append(" bindHeight=") + .append(r.proofBound() ? Long.toString(r.bindHeight()) : "(v1, unsigned)") + .append(" covers=") + .append(covers == null ? "NO SCHEDULED ENTRY" : "from block " + covers) + .append('\n'); + } + if (!set.uncoveredEntryBlocks().isEmpty()) { + b.append(" scheduled heights covered by NOTHING held here: ") + .append(set.uncoveredEntryBlocks()) + .append('\n'); + } + return b.toString(); + } + + /** The "what I found" block of a refusal: source, shape, both hashes, per-index fingerprints. */ + private static String describeRegistry(final Registry registry, final long chainId) { + final StringBuilder b = new StringBuilder(); + b.append(" REGISTRY ACTUALLY LOADED:\n"); + b.append(" source : ").append(registry.sourcePath()).append('\n'); + b.append(" source kind : ").append(registry.kind()).append('\n'); + b.append(" entries : ").append(registry.count()).append('\n'); + b.append(" addressBound: ").append(registry.addressBound()).append('\n'); + b.append(" format: ") + .append(registry.proofBound() ? "v2, D-146 binding proofs present" : "v1, NO binding proofs") + .append('\n'); + // A8: the report printed both v1 and v2 without saying WHICH one goes into genesis, and + // whoever took the last value off the screen took v1 and halted the fleet at H-1. The one + // that matters is now named explicitly, and it is the very one the gate compares: hashFor. + b.append(" >>> FOR config.pqRegistryHash: 0x") + .append(hashFor(registry, chainId)) + .append(" <<< this one, and only this one\n"); + if (registry.proofBound()) { + b.append(" bindHeight: ").append(registry.bindHeight()).append('\n'); + b.append(" canonical v2: 0x").append(hashV2(registry, chainId)).append(" (= the one above)\n"); + } + b.append(" canonical v1: 0x").append(hashV1(registry, chainId)) + .append(registry.proofBound() ? " (NOT this one: the registry carries proofs)\n" : " (= the one above)\n"); + b.append(" legacy v0 : 0x") + .append(hashV0Legacy(registry)) + .append(" (the older concat form, for cross-checking the genesis alloc anchor only)\n"); + b.append(" per-index fingerprints, keccak256(addr||pk) first 8 hex:\n"); + final int shown = Math.min(registry.count(), MAX_ENTRIES_IN_REPORT); + for (int i = 0; i < shown; i++) { + final Entry e = registry.entries.get(i); + b.append(" [") + .append(i) + .append("] ") + .append(fingerprint(registry, i)) + .append(" pkLen=") + .append(e.publicKey.length); + if (e.address != null) { + b.append(" addr=0x").append(hex(e.address)); + } + b.append('\n'); + } + if (registry.count() > shown) { + b.append(" ... ").append(registry.count() - shown).append(" more entries\n"); + } + return b.toString(); + } + + // =================================================================================== + // Small helpers. Deliberately local so this class needs nothing from Besu itself and + // can be compiled, hashed and tested standalone. + // =================================================================================== + + private static JsonNode readJson(final Path path) { + try { + return new ObjectMapper().readTree(Files.readAllBytes(path)); + } catch (final IOException e) { + throw new RegistryConfigException( + "AERE-PQC-REG-LOAD-13", + "AERE PQC A8: cannot read or parse JSON at '" + + path + + "': " + + e + + ". Refusing (fail-closed)."); + } + } + + private static int parseIndexOrThrow(final String raw, final String source) { + try { + final int i = Integer.parseInt(raw.trim()); + if (i < 0) { + throw new NumberFormatException("negative"); + } + return i; + } catch (final NumberFormatException e) { + throw new RegistryConfigException( + "AERE-PQC-REG-LOAD-14", + "AERE PQC A8: registry '" + + source + + "' has the unrecognised key '" + + raw + + "'. Every key must be 'count', a non-negative validator index, or '.addr'. " + + "Refusing (fail-closed): the old loader SKIPPED keys it did not understand and " + + "logged a warning, which means a typo'd index silently produced a smaller registry " + + "that still hashed to something."); + } + } + + /** + * AERE D-146. Parse an OPTIONAL non-negative header field: absent means -1, present means it must + * be a well formed non-negative number. Absent-or-garbage is never collapsed into a default, + * because a default is how a threshold quietly becomes zero. + */ + private static long parseOptionalLong( + final String raw, final String what, final String source) { + if (raw == null || raw.trim().isEmpty()) { + return -1L; + } + final long v; + try { + v = Long.parseLong(raw.trim()); + } catch (final NumberFormatException e) { + throw new RegistryConfigException( + "AERE-PQC-REG-LOAD-23", + "AERE PQC D-146: registry '" + + source + + "' has " + + what + + "='" + + raw + + "', which is not a number. Refusing (fail-closed)."); + } + if (v < 0) { + throw new RegistryConfigException( + "AERE-PQC-REG-LOAD-23", + "AERE PQC D-146: registry '" + + source + + "' has a negative " + + what + + " (" + + v + + "). Refusing (fail-closed)."); + } + return v; + } + + private static int parsePositiveInt(final String raw, final String what, final String source) { + try { + final int v = Integer.parseInt(raw.trim()); + if (v <= 0) { + throw new NumberFormatException("not positive"); + } + return v; + } catch (final NumberFormatException e) { + throw new RegistryConfigException( + "AERE-PQC-REG-LOAD-15", + "AERE PQC A8: registry '" + + source + + "' has a malformed " + + what + + " '" + + raw + + "'. Refusing (fail-closed)."); + } + } + + /** + * Strict hex: optional {@code 0x}, then an EVEN number of hex digits and nothing else. + * + *

Deliberately NOT the lenient parser the old loader used. Lenient parsing accepts an odd digit + * count and silently left-pads, so a public key that lost one character would load as a DIFFERENT + * key with no complaint, and hash to something that looks perfectly valid. + */ + private static byte[] strictHex(final String raw, final String what, final String source) { + String s = raw == null ? "" : raw.trim(); + if (s.startsWith("0x") || s.startsWith("0X")) { + s = s.substring(2); + } + if (s.isEmpty() || (s.length() & 1) == 1) { + throw new RegistryConfigException( + "AERE-PQC-REG-LOAD-16", + "AERE PQC A8: registry '" + + source + + "' " + + what + + " has " + + s.length() + + " hex digits, which is " + + (s.isEmpty() ? "empty" : "ODD") + + ". Refusing (fail-closed): a lenient parser would left-pad this and load a " + + "DIFFERENT key without saying anything."); + } + final byte[] out = new byte[s.length() / 2]; + for (int i = 0; i < out.length; i++) { + final int hi = Character.digit(s.charAt(2 * i), 16); + final int lo = Character.digit(s.charAt(2 * i + 1), 16); + if (hi < 0 || lo < 0) { + throw new RegistryConfigException( + "AERE-PQC-REG-LOAD-17", + "AERE PQC A8: registry '" + + source + + "' " + + what + + " contains a non-hex character at position " + + (2 * i) + + ". Refusing (fail-closed)."); + } + out[i] = (byte) ((hi << 4) | lo); + } + return out; + } + + private static String normHash(final String raw, final String source) { + String s = raw == null ? "" : raw.trim(); + if (s.startsWith("0x") || s.startsWith("0X")) { + s = s.substring(2); + } + s = s.toLowerCase(Locale.ROOT); + if (s.length() != 64) { + throw new RegistryConfigException( + "AERE-PQC-REG-SCHED-06", + "AERE PQC A8: " + + source + + " carries the hash '" + + raw + + "' which is " + + s.length() + + " hex digits, not 64. Refusing to start (fail-closed)."); + } + for (int i = 0; i < 64; i++) { + if (Character.digit(s.charAt(i), 16) < 0) { + throw new RegistryConfigException( + "AERE-PQC-REG-SCHED-07", + "AERE PQC A8: " + source + " hash '" + raw + "' is not hexadecimal. Refusing to start."); + } + } + return s; + } + + private static void writeAll(final java.io.ByteArrayOutputStream out, final byte[] b) { + out.write(b, 0, b.length); + } + + private static byte[] uint32be(final int v) { + return new byte[] {(byte) (v >>> 24), (byte) (v >>> 16), (byte) (v >>> 8), (byte) v}; + } + + private static byte[] uint64be(final long v) { + final byte[] b = new byte[8]; + for (int i = 0; i < 8; i++) { + b[i] = (byte) (v >>> (56 - 8 * i)); + } + return b; + } + + private static String keccakHex(final byte[] input) { + final KeccakDigest kd = new KeccakDigest(256); + kd.update(input, 0, input.length); + final byte[] d = new byte[32]; + kd.doFinal(d, 0); + return hex(d); + } + + private static String hex(final byte[] b) { + final StringBuilder s = new StringBuilder(b.length * 2); + for (final byte x : b) { + s.append(Character.forDigit((x >> 4) & 0xf, 16)).append(Character.forDigit(x & 0xf, 16)); + } + return s.toString(); + } +} diff --git a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqRegistryHashTool.java b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqRegistryHashTool.java new file mode 100755 index 000000000..14071c5e4 --- /dev/null +++ b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqRegistryHashTool.java @@ -0,0 +1,481 @@ +/* + * Copyright contributors to Besu / AERE Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * D-145. The tool that writes and checks the two configuration values without which the D2 repair + * changes nothing. + * + *

WHY IT LIVES IN THE CONSENSUS MODULE AND NOT IN A SCRIPT. The value being computed is a + * keccak digest over a domain-separated, length-prefixed, chain-bound pre-image, and the node + * refuses to start when what it computes differs by one bit from what genesis names. A second + * implementation in another language is a second thing that can drift, and the drift would show up + * as seven validators refusing to start. This tool calls the SAME methods the node calls, from the + * same jar, so "the tool says the hash is X" and "the node computes X" cannot come apart. Run it + * from a node's own distribution: + * + *

+ *   java -cp 'besu/lib/*' org.hyperledger.besu.consensus.common.bft.PqRegistryHashTool \
+ *        verify --chain-id 2800 --genesis ./genesis-2800.json \
+ *        --history ./falcon/registry-epoch-0.properties
+ * 
+ * + *

THREE VERBS. + * + *

    + *
  • {@code hash} prints the canonical hash of every registry file named. This is the value + * that goes into genesis. + *
  • {@code generate} prints the exact genesis fragment and the exact + * {@code aere.falcon.registry.history} line, given an arming height and one registry file per + * epoch. It writes nothing by itself: arming is a founder decision, and a tool that edits a + * live genesis is a tool that can arm a chain by accident. + *
  • {@code verify} recomputes everything from the files on THIS machine and answers one + * question: is there a scheduled height this node could not resolve. Exit 0 green, 1 red, 2 + * not measurable. + *
+ * + *

THE NEGATIVE CONTROL IS A VERB, not a promise: {@code verify --CONTROL-NEGATIV} changes one + * hex digit of the first required hash in memory and requires the verification to go RED. A check + * that has never failed cannot be trusted, so the tool proves it can fail every time it is run + * that way. + */ +public final class PqRegistryHashTool { + + private PqRegistryHashTool() {} + + /** + * Entry point. + * + * @param args the verb and its options + */ + public static void main(final String[] args) { + System.exit(run(args)); + } + + /** + * The tool, as a function of its arguments, so a test can drive it without exiting the JVM. + * + * @param args the verb and its options + * @return 0 green, 1 red, 2 not measurable + */ + public static int run(final String[] args) { + if (args.length == 0) { + utilizare(); + return 2; + } + final Map o = optiuni(args); + final long chainId = Long.parseLong(o.getOrDefault("chain-id", "2800")); + try { + switch (args[0]) { + case "hash": + return hash(o, chainId); + case "generate": + return generate(o, chainId); + case "verify": + return verify(o, chainId); + default: + utilizare(); + return 2; + } + } catch (final PqRegistryHash.RegistryConfigException e) { + System.out.println("RED: " + e.getMessage()); + return 1; + } catch (final RuntimeException e) { + System.out.println("NOT MEASURED: " + e); + return 2; + } + } + + // --------------------------------------------------------------------------------------------- + + private static int hash(final Map o, final long chainId) { + final List files = files(o, "registry"); + if (files.isEmpty()) { + System.out.println("NOT MEASURED: --registry [,...] is missing"); + return 2; + } + System.out.println("chainId=" + chainId + " (goes INTO the pre-image; 2800 and 442807 give different hashes for the same registry)"); + for (final Path p : files) { + final PqRegistryHash.Registry r = PqRegistryHash.loadAuto(p); + // AERE D-C (2026-08-06): hashFor, not hashV1. For a registry that carries binding proofs, + // hashV1 is a number NOTHING in the node ever compares against: the guard compares hashFor, + // that is hashV2. The same mistake, in the `generate` verb below, writes into genesis a hash + // the node will never recognise, and then all seven start and the chain stops at H-1. + // On top of that, hashV1 does NOT tell two rotation epochs of the same fleet apart, because the + // bind height does not enter the v1 pre-image; so it cannot serve even as an epoch identifier + // for diagnostics. + System.out.println( + "0x" + + PqRegistryHash.hashFor(r, chainId) + + " " + + p + + " (entries=" + + r.count() + + ", address-bound=" + + r.addressBound() + + ", format=" + + (r.proofBound() ? "v2, bind-height=" + r.bindHeight() : "v1, no proofs") + + ")"); + } + return 0; + } + + // --------------------------------------------------------------------------------------------- + + private static int generate(final Map o, final long chainId) { + final String armingHeightRaw = o.get("arming-height"); + if (armingHeightRaw == null) { + System.out.println("NOT MEASURED: --arming-height is missing. The first entry of the"); + System.out.println(" schedule must be EXACTLY at aere.pq.anchorBlock; see case D"); + System.out.println(" of dovezi-d2-2026-08-06."); + return 2; + } + final long h = Long.parseLong(armingHeightRaw); + final List files = files(o, "registry"); + if (files.isEmpty()) { + System.out.println("NOT MEASURED: --registry is missing"); + return 2; + } + // The epochs: the first at the arming height, then one per --rotation =. + final Map epoci = new LinkedHashMap<>(); + epoci.put(h, files.get(0)); + for (final String rot : list(o.get("rotation"))) { + final int eq = rot.indexOf('='); + if (eq < 0) { + System.out.println("NOT MEASURED: --rotation is written =, got: " + rot); + return 2; + } + epoci.put(Long.parseLong(rot.substring(0, eq).trim()), Path.of(rot.substring(eq + 1).trim())); + } + + final List inaltimi = new ArrayList<>(epoci.keySet()); + inaltimi.sort(Long::compare); + if (inaltimi.get(0) != h) { + System.out.println("RED: the first epoch is at " + inaltimi.get(0) + ", not at the arming height " + h); + return 1; + } + + // NO COMMENT INSIDE THE FRAGMENT, and that is a measurement, not a matter of taste. The first + // form of this method put the file path after every entry, as `// /path/registry.json`. + // The fragment LOOKS fine and CANNOT BE USED: genesis is read with Jackson without + // ALLOW_COMMENTS, so a node handed one of those refuses to start with + // [AERE-PQC-REG-LOAD-13] "Unexpected character ('/')", and it then refuses EVERY header from + // the arming height upwards. Measured 2026-08-06, cases U3/U4 in d2-v2/dovezi/controale/. + // Whatever is explanation is printed outside the JSON, on lines beginning with #. + final StringBuilder json = new StringBuilder(); + json.append(" \"pqRegistryHash\": [\n"); + final StringBuilder history = new StringBuilder(); + final StringBuilder harta = new StringBuilder(); + for (int i = 0; i < inaltimi.size(); i++) { + final long b = inaltimi.get(i); + final Path p = epoci.get(b); + final PqRegistryHash.Registry reg = PqRegistryHash.loadAuto(p); + // AERE D-C (2026-08-06). THIS IS THE DANGEROUS VERB: what is printed here gets pasted into + // genesis, and genesis is the document all seven nodes hold identical. hashV1 next to a + // registry that carries proofs writes into genesis a number the node's guard (hashFor) never + // produces, so all seven nodes start, all report the registry loaded, and the chain stops at + // H-1. A wrong indication is more dangerous than a missing one. + final String hash = PqRegistryHash.hashFor(reg, chainId); + // AERE D-B (2026-08-06). The recipe we print has to be the one the NEW code accepts. Since + // 2026-08-06 the node refuses to start (AERE-PQC-REG-BIND-08) on a registry forced in at a + // height its proofs did not sign. If the tool printed that recipe, it would manufacture + // exactly the configuration the node rejects, and it would do so in a file that reaches all + // seven at once. + if (reg.proofBound() && reg.bindHeight() != b) { + System.out.println( + "RED: registry " + + p + + " has bind height " + + reg.bindHeight() + + ", while here it is scheduled from block " + + b + + ". The two numbers must be EQUAL: every row of the registry carries a proof of " + + "possession and a claim, and both sign the bind height. A registry forced in from " + + "another block is an activation day no validator signed, and the node refuses to " + + "start on it with AERE-PQC-REG-BIND-08. It is not repaired by editing the " + + "bindHeight field: the proofs are over it, and the file would no longer load at " + + "all (AERE-PQC-REG-BIND-03). It is repaired either by scheduling the epoch at " + + reg.bindHeight() + + ", or by re-signing the registry for " + + b + + ", which means the key ceremony and a NEW hash."); + return 1; + } + json.append(" {\"block\": ").append(b).append(", \"hash\": \"0x").append(hash).append("\"}"); + if (i < inaltimi.size() - 1) { + json.append(','); + } + json.append('\n'); + harta.append("# block ").append(b).append(" -> ").append(p.toAbsolutePath()).append('\n'); + if (history.length() > 0) { + history.append(','); + } + history.append(p.toAbsolutePath()); + } + json.append(" ]"); + + System.out.println("# 1. In the genesis of chain " + chainId + ", inside the \"config\" object:"); + System.out.println("# The fragment below is VALID JSON and is pasted as it stands. No comments"); + System.out.println("# are added to it: genesis is read WITHOUT ALLOW_COMMENTS and the node refuses to start."); + System.out.println(json); + System.out.println("# Which registry file stands behind each epoch:"); + System.out.print(harta); + System.out.println(); + System.out.println("# 2. On EVERY node, the second client included, in BESU_OPTS:"); + System.out.println("-Daere.falcon.registry.history=" + history); + System.out.println(); + System.out.println("# 3. The order, and it is not negotiable: the registry files are placed on all"); + System.out.println("# seven AND the hash goes into genesis BEFORE the restart. A node armed"); + System.out.println("# without a schedule refuses every header from the arming height upwards."); + System.out.println("# 4. After they are placed, on each node:"); + System.out.println("# PqRegistryHashTool verify --chain-id " + chainId + " --genesis --history "); + return 0; + } + + // --------------------------------------------------------------------------------------------- + + private static int verify(final Map o, final long chainId) { + final String genesisRaw = o.get("genesis"); + if (genesisRaw == null) { + System.out.println("NOT MEASURED: --genesis is missing"); + return 2; + } + final Path genesis = Path.of(genesisRaw); + if (!Files.isReadable(genesis)) { + System.out.println("NOT MEASURED: genesis cannot be read: " + genesis); + return 2; + } + + PqRegistryHash.Schedule schedule = PqRegistryHash.loadScheduleFromGenesis(genesis); + if (!schedule.enforced()) { + System.out.println( + "RED: " + + genesis + + " does not carry config.pqRegistryHash. This is D-145 exactly as it was measured: " + + "the machinery exists, it is complete, and it is OFF. A node armed without a " + + "schedule falls back on TODAY's registry and says ACCEPTED for a header it has " + + "bound to no height (case D of dovezi-d2-2026-08-06)."); + return 1; + } + + boolean controlNegativ = o.containsKey("CONTROL-NEGATIV"); + if (controlNegativ) { + schedule = strica(schedule); + System.out.println( + "NEGATIVE CONTROL: the first required hash was changed by ONE hex character, in memory. " + + "The verification below MUST come out RED."); + } + + final List history = files(o, "history"); + final List tinute = new ArrayList<>(); + for (final Path p : history) { + tinute.add(PqRegistryHash.loadAuto(p)); + } + if (tinute.isEmpty()) { + System.out.println( + "WARNING: --history is empty, so no registry was loaded. On a real node the signing " + + "registry is added automatically; here it is asked for explicitly, so that nothing " + + "is reported green on the strength of what was not measured."); + } + + final PqRegistryHash.RegistrySet set = PqRegistryHash.buildSet(schedule, tinute, chainId); + + System.out.println("chainId = " + chainId); + System.out.println("genesis = " + genesis); + System.out.println("schedule source = " + schedule.source()); + System.out.println("epochs scheduled = " + schedule.entries().size()); + for (final PqRegistryHash.ScheduleEntry e : schedule.entries()) { + final boolean acoperita = !set.uncoveredEntryBlocks().contains(e.block()); + System.out.println( + " block " + e.block() + " 0x" + e.hash() + " " + (acoperita ? "COVERED" : "NOT COVERED")); + } + System.out.println("registries held = " + set.count()); + for (final Path p : history) { + // AERE D-C: hashFor. This line sits immediately under the list of scheduled epochs printed + // with their hashes; two numbers laid one under the other so that they get compared by eye, + // and computed with two different functions, are a comparison that can never match. + final PqRegistryHash.Registry r = PqRegistryHash.loadAuto(p); + System.out.println( + " " + + PqRegistryHash.hashFor(r, chainId) + + " " + + p + + (r.proofBound() ? " (bind-height=" + r.bindHeight() + ")" : " (v1)")); + } + final String armareRaw = o.get("arming-height"); + int rc = 0; + // AERE D-B: the registries that reproduce the required hash and did NOT sign that height. This + // is exactly what the node now refuses to start on; until 2026-08-06 the node started and the + // tool said nothing. + for (final PqRegistryHash.Misbound m : set.misbound()) { + System.out.println( + "RED: " + + m.registryPath() + + " reproduces the hash required at block " + + m.entryBlock() + + ", but its proofs sign height " + + m.signedHeight() + + ". The node refuses to start with AERE-PQC-REG-BIND-08."); + rc = 1; + } + if (armareRaw != null) { + final long h = Long.parseLong(armareRaw); + final long first = schedule.entries().get(0).block(); + if (first != h) { + System.out.println( + "RED: the first entry of the schedule is at " + + first + + ", while aere.pq.anchorBlock is " + + h + + ". They must be EQUAL. If the first entry is higher, the heights between H and it " + + "are bound to nothing and the node falls back on the head registry exactly where " + + "the certificate begins to matter."); + rc = 1; + } else { + System.out.println("OK: the first epoch is exactly at the arming height " + h + "."); + } + } else { + System.out.println( + "NOT MEASURED: --arming-height was not given, so it was NOT checked that the first " + + "epoch coincides with aere.pq.anchorBlock."); + } + + final List neacoperite = set.uncoveredEntryBlocks(); + if (!neacoperite.isEmpty()) { + System.out.println( + "RED: heights " + + neacoperite + + " are covered by NOTHING this node holds. Every header from such a height upwards " + + "will be REFUSED, and a node syncing from genesis stops there. Name the missing " + + "file in aere.falcon.registry.history."); + rc = 1; + } else { + System.out.println("OK: every epoch in the schedule is covered by a registry this node holds."); + } + + // Positive probe: the registry in force at the height of every epoch resolves right now. + for (final PqRegistryHash.ScheduleEntry e : schedule.entries()) { + final Optional r = + PqRegistryHash.registryAt(schedule, set, e.block()); + if (r.isEmpty()) { + System.out.println("RED: registryAt(" + e.block() + ") resolves nothing."); + rc = 1; + } else if (!PqRegistryHash.matchesAt(schedule, set, e.block(), chainId)) { + System.out.println("RED: registryAt(" + e.block() + ") resolves a registry with a different hash."); + rc = 1; + } + } + + if (controlNegativ) { + if (rc == 1) { + System.out.println("NEGATIVE CONTROL OK: with a single character changed, the verification is RED."); + return 0; + } + System.out.println( + "NEGATIVE CONTROL FAILED: a hash was changed and the verification stayed GREEN. The " + + "verification cannot be trusted."); + return 1; + } + System.out.println(rc == 0 ? "GREEN" : "RED"); + return rc; + } + + // --------------------------------------------------------------------------------------------- + + /** Change exactly one hex digit of the first required hash, through the real parser. */ + private static PqRegistryHash.Schedule strica(final PqRegistryHash.Schedule s) { + final StringBuilder sb = new StringBuilder("{\"config\":{\"pqRegistryHash\":["); + for (int i = 0; i < s.entries().size(); i++) { + final PqRegistryHash.ScheduleEntry e = s.entries().get(i); + String h = e.hash(); + if (i == 0) { + final char c = h.charAt(h.length() - 1); + h = h.substring(0, h.length() - 1) + (c == '0' ? '1' : '0'); + } + if (i > 0) { + sb.append(','); + } + sb.append("{\"block\":").append(e.block()).append(",\"hash\":\"0x").append(h).append("\"}"); + } + sb.append("]}}"); + try { + final Path t = Files.createTempFile("aere-control-negativ-", ".json"); + t.toFile().deleteOnExit(); + Files.writeString(t, sb.toString()); + return PqRegistryHash.loadScheduleFromGenesis(t); + } catch (final java.io.IOException io) { + throw new IllegalStateException("cannot write the negative-control file: " + io, io); + } + } + + private static List files(final Map o, final String key) { + final List out = new ArrayList<>(); + for (final String s : list(o.get(key))) { + out.add(Path.of(s)); + } + return out; + } + + private static List list(final String raw) { + final List out = new ArrayList<>(); + if (raw == null || raw.isBlank()) { + return out; + } + for (final String s : com.google.common.base.Splitter.on(',').split(raw)) { + if (!s.trim().isEmpty()) { + out.add(s.trim()); + } + } + return out; + } + + private static Map optiuni(final String[] args) { + final Map o = new LinkedHashMap<>(); + for (int i = 1; i < args.length; i++) { + if (!args[i].startsWith("--")) { + continue; + } + final String k = args[i].substring(2); + if (i + 1 < args.length && !args[i + 1].startsWith("--")) { + final String prev = o.get(k); + o.put(k, prev == null ? args[i + 1] : prev + "," + args[i + 1]); + i++; + } else { + o.put(k, ""); + } + } + return o; + } + + private static void utilizare() { + System.out.println("D-145. The height-indexed registry schedule: hash, generate, verify."); + System.out.println(); + System.out.println(" hash --chain-id 2800 --registry [,...]"); + System.out.println(" generate --chain-id 2800 --arming-height --registry "); + System.out.println(" [--rotation

= ...]"); + System.out.println(" verify --chain-id 2800 --genesis --history [,...]"); + System.out.println(" [--arming-height ] [--CONTROL-NEGATIV]"); + System.out.println(); + System.out.println("Exit code: 0 green, 1 red, 2 not measurable."); + } +} diff --git a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqSchemeSchedule.java b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqSchemeSchedule.java new file mode 100755 index 000000000..a4d1bd43e --- /dev/null +++ b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqSchemeSchedule.java @@ -0,0 +1,139 @@ +/* + * AERE crypto-agility, step 5: the height-indexed scheme schedule. + * + * WHAT IT IS. The same shape as aere.pq.anchorMinSeals ("H:K,H:K,..."), but the value at each + * step is a SET of scheme ids: "14000000:falcon-512,15500000:falcon-512+slh-dsa-128s" reads + * "from 14,000,000 anchors carry Falcon; from 15,500,000 they carry Falcon AND SLH-DSA". + * Changing the mathematics of the chain becomes one property plus keys, never a code edit - + * that is the whole point of the abstraction layer. + * + * THE D-147 LESSON, APPLIED AT THE LOADER. The min-seals schedule once accepted a shape whose + * DANGEROUS step was later in the schedule, because validation looked only at the first step. + * Here every rule runs over the WHOLE schedule at parse time, and the armability gate + * (firstUnsatisfied) walks every step against the registry's per-scheme coverage: arming a + * threshold K under a scheme whose coverage is below K is a chain stop, so it must be refused + * at configuration time, loudly, before any node boots with it. + * + * SEMANTICS OF "BEFORE THE FIRST STEP": schemesAt returns the empty set, which callers read as + * "the v2 scheme world is not armed here" (the legacy untagged Falcon certificate governs). + * Empty is never a default INSIDE the schedule: a step with zero schemes is a parse refusal. + */ +package org.hyperledger.besu.consensus.common.bft; + +import com.google.common.base.Splitter; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +/** Immutable, validated height-to-scheme-set schedule. */ +public final class PqSchemeSchedule { + + /** One step: from {@code fromBlock} (inclusive) the anchor carries {@code schemeIds}. */ + public record Step(long fromBlock, Set schemeIds) {} + + private final List steps; + + private PqSchemeSchedule(final List steps) { + this.steps = steps; + } + + /** Parse "H:scheme[+scheme...],H:...". Refuses the WHOLE schedule on any defect: unknown or + * repeated scheme in a step, empty step, non-increasing heights, negative height, garbage. */ + public static PqSchemeSchedule parse(final String raw) { + if (raw == null || raw.isBlank()) { + throw new IllegalArgumentException("AERE PQ ORAR-SCHEME: empty schedule"); + } + final List steps = new ArrayList<>(); + long lastHeight = -1; + for (final String piesa : Splitter.on(',').split(raw)) { + final List parti = Splitter.on(':').splitToList(piesa.trim()); + if (parti.size() != 2) { + throw new IllegalArgumentException( + "AERE PQ ORAR-SCHEME: step '" + piesa.trim() + "' is not H:schemes"); + } + final long h; + try { + h = Long.parseLong(parti.get(0).trim()); + } catch (final NumberFormatException e) { + throw new IllegalArgumentException( + "AERE PQ ORAR-SCHEME: height '" + parti.get(0).trim() + "' is not a number"); + } + if (h < 0) { + throw new IllegalArgumentException("AERE PQ ORAR-SCHEME: negative height " + h); + } + if (h <= lastHeight) { + throw new IllegalArgumentException( + "AERE PQ ORAR-SCHEME: heights must strictly increase (" + + lastHeight + + " then " + + h + + ") - a schedule read out of order would arm the wrong mathematics"); + } + lastHeight = h; + final Set schemes = new LinkedHashSet<>(); + for (final String id : Splitter.on('+').split(parti.get(1))) { + final String curat = id.trim(); + if (curat.isEmpty()) { + throw new IllegalArgumentException( + "AERE PQ ORAR-SCHEME: step at " + h + " carries an empty scheme name"); + } + if (SealSchemes.byId(curat).isEmpty()) { + throw new IllegalArgumentException( + "AERE PQ ORAR-SCHEME: step at " + h + " names UNKNOWN scheme '" + curat + + "' - refusing the whole schedule"); + } + if (!schemes.add(curat)) { + throw new IllegalArgumentException( + "AERE PQ ORAR-SCHEME: step at " + h + " repeats scheme '" + curat + "'"); + } + } + if (schemes.isEmpty()) { + throw new IllegalArgumentException( + "AERE PQ ORAR-SCHEME: step at " + h + " has no schemes at all"); + } + steps.add(new Step(h, Set.copyOf(schemes))); + } + return new PqSchemeSchedule(List.copyOf(steps)); + } + + /** The scheme set in force at {@code height}: the last step at or below it, or the empty set + * when the schedule has not started yet (= v2 not armed, legacy governs). */ + public Set schemesAt(final long height) { + Set inForce = Set.of(); + for (final Step s : steps) { + if (s.fromBlock() <= height) { + inForce = s.schemeIds(); + } else { + break; + } + } + return inForce; + } + + /** All steps, ascending. */ + public List steps() { + return steps; + } + + /** The armability gate: walk EVERY step and every scheme in it against the registry's + * per-scheme coverage; the first (height, scheme) whose coverage is below {@code minSeals} + * is returned as the refusal, with numbers. Empty means the whole schedule is armable. + * This is the D-147 discipline: the dangerous step may be the LAST one, so all are walked. */ + public Optional firstUnsatisfied(final HybridSignerRegistry registry, final int minSeals) { + for (final Step s : steps) { + for (final String scheme : s.schemeIds()) { + final int acoperire = registry.coverage(scheme); + if (acoperire < minSeals) { + return Optional.of( + "step at height " + s.fromBlock() + " arms scheme '" + scheme + + "' with required seals " + minSeals + " but the registry covers only " + + acoperire + " validator(s) - arming this would stop the chain"); + } + } + } + return Optional.empty(); + } +} diff --git a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqSealCache.java b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqSealCache.java new file mode 100755 index 000000000..dac0d63dd --- /dev/null +++ b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqSealCache.java @@ -0,0 +1,431 @@ +/* + * Copyright contributors to Besu / AERE Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +import org.hyperledger.besu.datatypes.Hash; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * AERE ANCHOR V2: the bounded store of Falcon seals this node HEARD for recent blocks, in memory and + * - for the chain head only - on disk. + * + *

WHAT IT IS FOR. The proposer of block N must carry a certificate over block N-1. Under + * V2 that certificate is chosen by the proposer out of what it heard, so the seals gossiped on + * Commit messages have to survive from the height where they arrive to the height where the next + * block is built. That is all this class does. + * + *

KEYED ON THE ON-CHAIN BLOCK HASH, NEVER ON THE COMMIT DIGEST. The digest carried by a Commit + * message is the committed-seal hash, which INCLUDES the round number, so it differs between rounds + * for one and the same block. The producer looks the certificate up by {@code + * parentHeader.getHash()}, which is the round-independent on-chain hash. Storing under anything + * else would silently produce empty certificates at every round change. + * + *

AERE PERSISTENTA-SIGILII (2026-08-05): PERSISTENT FOR THE CHAIN HEAD, AND THE OLD REASONING FOR + * NOT BEING SO WAS MEASURED WRONG. This class used to say, in this exact place, that losing the map + * on restart costs "at most one proposer turn per restart". That reasoning is correct for ONE node + * restarted inside a LIVE fleet, which is the case it was written for. For a fleet restarted + * ENTIRELY it is false, and the falsehood was measured on a seven-node network: with K>0 every + * node came back holding zero seals, no certificate could reach K, nobody could propose, so no + * Commit was ever sent, so no node ever heard a seal again. The chain was dead, permanently, and no + * amount of waiting healed it. The seals over M(head) exist nowhere else: they travel only on the + * Commit messages of the head block, and those are never replayed. + * + *

AND IT IS NOT DEFECT A8 IN ANOTHER COAT. A8 was a registry of public keys read from a file and + * BELIEVED. Every seal read back here is re-verified, cryptographically, against the anchored + * registry over M rebuilt from the header this process just loaded - see {@link PqSealStore}. A + * forged file cannot inject a seal without forging a Falcon-512 signature; the worst it achieves is + * the empty cache an absent file already gives. Persistence is OFF unless a caller enables it, and + * the only caller that does is the QBFT controller builder, only when the anchor is actually armed. + * + *

WHAT IT DOES NOT DO. The in-memory path verifies nothing. Whether a seal is valid, whether its + * index maps to an eligible validator, and whether there are enough of them, are decided at + * SELECTION time by the producer, against the message M of the specific parent. A seal that was + * signed under the pre-fork message simply fails that check and is dropped, which is what makes the + * height switch safe without any coordination beyond the shared activation height. + */ +public final class PqSealCache { + + private static final Logger LOG = LoggerFactory.getLogger(PqSealCache.class); + + /** How many distinct block hashes are retained. Several rounds at one height each add one. */ + private static final int MAX_ENTRIES = 1024; + + /** How far below the highest seen height entries are kept, in blocks. */ + private static final long HEIGHT_WINDOW = 256L; + + private static final PqSealCache INSTANCE = new PqSealCache(); + + private final LinkedHashMap byHash = new LinkedHashMap<>(); + private long highestSeen = -1L; + + /** Guards the file, never the map, so a slow disk cannot hold up the consensus thread's map. */ + private final Object writeLock = new Object(); + + private volatile Path persistenceFile; + private volatile long persistenceChainId; + + private long persistedBlock = -1L; + private Hash persistedHash; + private int persistedCount = -1; + + private PqSealCache() {} + + /** + * The process-wide cache. + * + * @return the singleton + */ + public static PqSealCache instance() { + return INSTANCE; + } + + /** + * Turn on writing the chain head's seals to the node's data directory. + * + *

The path is DERIVED from the data directory Besu was started with, never configured + * separately: a second configurable path is a second thing that can be pointed at the wrong node's + * state, and this file is per-node by construction. + * + * @param dataDirectory the node's data directory, or null to leave persistence off + * @param chainId the chain id used in the M pre-image, which must be the SAME value the producer + * uses or every restored seal would fail verification + */ + public void enablePersistence(final Path dataDirectory, final long chainId) { + if (dataDirectory == null) { + LOG.warn( + "AERE PERSISTENTA-SIGILII: no data directory was supplied, so heard seals stay in memory " + + "only. A simultaneous restart of the whole fleet with K>0 stops the chain."); + return; + } + this.persistenceChainId = chainId; + this.persistenceFile = PqSealStore.fileIn(dataDirectory); + LOG.info( + "AERE PERSISTENTA-SIGILII: heard seals for the chain head are kept in {} (chainId={}). " + + "Every seal read back is re-verified against the anchored registry before it is used.", + this.persistenceFile, + chainId); + } + + /** + * Turn persistence off again. For tests, and for an operator who wants the previous behaviour. + */ + public void disablePersistence() { + this.persistenceFile = null; + synchronized (writeLock) { + persistedBlock = -1L; + persistedHash = null; + persistedCount = -1; + } + } + + /** + * The file heard seals are written to, or null when persistence is off. + * + * @return the seal file, or null + */ + public Path persistenceFile() { + return persistenceFile; + } + + /** + * Refill the cache from disk for a specific block, normally this node's chain head at startup. + * + *

Call this AFTER the Falcon registry has been activated, or every seal will be rejected for + * the entirely correct reason that the registry cannot resolve its index yet, and the restore will + * silently do nothing. + * + * @param blockNumber the head block's height + * @param onchainBlockHash the head block's ON-CHAIN hash + * @param registry the anchored registry every restored seal is re-verified against + * @return how many seals survived verification and entered the cache + */ + public int restoreFromDisk( + final long blockNumber, final Hash onchainBlockHash, final PqSignerRegistry registry) { + final Path file = persistenceFile; + if (file == null) { + return 0; + } + List verified; + try { + verified = + PqSealStore.readVerified( + file, persistenceChainId, blockNumber, onchainBlockHash, registry); + if (verified.isEmpty()) { + // MAGAZIA TINE VARFUL (D-330): the main file may hold a later height that never imported; + // the previous slot may hold exactly the head. Same verification, same registry. + final Path previous = file.resolveSibling(PqSealStore.PREVIOUS_FILE_NAME); + verified = + PqSealStore.readVerified( + previous, persistenceChainId, blockNumber, onchainBlockHash, registry); + if (!verified.isEmpty()) { + LOG.info( + "AERE PERSISTENTA-SIGILII: {} seal(s) for the chain head {} restored from the PREVIOUS slot {}" + + " (the main file held a later height).", + verified.size(), + blockNumber, + previous); + } + } + } catch (final RuntimeException e) { + // readVerified is written not to throw; this is the belt on top of the braces, because an + // exception escaping here would turn a lost cache into a node that refuses to start. + LOG.warn( + "AERE PERSISTENTA-SIGILII: restoring seals from {} failed unexpectedly ({}); the cache " + + "starts empty, which is exactly the behaviour before this file existed.", + file, + e.toString()); + return 0; + } + if (verified.isEmpty()) { + return 0; + } + synchronized (this) { + final Entry entry = + byHash.computeIfAbsent(onchainBlockHash, unused -> new Entry(blockNumber)); + for (final FalconSeal seal : verified) { + entry.seals.putIfAbsent(seal.getValidatorIndex(), seal); + } + if (blockNumber > highestSeen) { + highestSeen = blockNumber; + } + prune(); + } + return verified.size(); + } + + /** + * Remember the seals heard for a block. Idempotent per validator index: the first seal seen for + * an index wins, so a later duplicate cannot displace it. + * + * @param blockNumber the height of the block the seals attest + * @param onchainBlockHash the ON-CHAIN hash of that block + * @param seals the seals heard, possibly empty + */ + public void record( + final long blockNumber, final Hash onchainBlockHash, final Collection seals) { + final List toPersist; + synchronized (this) { + if (onchainBlockHash == null || seals == null || seals.isEmpty()) { + return; + } + final Entry entry = + byHash.computeIfAbsent(onchainBlockHash, unused -> new Entry(blockNumber)); + for (final FalconSeal seal : seals) { + if (seal != null && seal.getValidatorIndex() >= 0 && seal.getSignature() != null) { + entry.seals.putIfAbsent(seal.getValidatorIndex(), seal); + } + } + if (blockNumber > highestSeen) { + highestSeen = blockNumber; + } + prune(); + if (persistenceFile == null || blockNumber < highestSeen) { + // Only the HIGHEST block is worth a file: the producer of the next block asks for the head's + // seals and nothing else, and writing older heights would just churn the disk. + return; + } + toPersist = PqAnchor.sortedByIndex(new ArrayList<>(entry.seals.values())); + } + // Deliberately OUTSIDE the map lock: a file write must never be able to stall a caller that only + // wants to read the cache. + persist(blockNumber, onchainBlockHash, toPersist); + } + + private void persist( + final long blockNumber, final Hash onchainBlockHash, final List seals) { + final Path file = persistenceFile; + if (file == null) { + return; + } + synchronized (writeLock) { + if (blockNumber < persistedBlock) { + return; + } + if (blockNumber == persistedBlock + && onchainBlockHash.equals(persistedHash) + && seals.size() <= persistedCount) { + return; + } + try { + // MAGAZIA TINE VARFUL (D-330, 2026-09-03): before the main file moves to a HIGHER height, what it + // held becomes the previous slot. The main file may hold a proposal that never imports; the + // previous slot then still holds the head, and a restart can restore the head's seals from it. + if (persistedBlock >= 0 && blockNumber > persistedBlock && java.nio.file.Files.isRegularFile(file)) { + java.nio.file.Files.move( + file, + file.resolveSibling(PqSealStore.PREVIOUS_FILE_NAME), + java.nio.file.StandardCopyOption.REPLACE_EXISTING); + } + PqSealStore.writeAtomically( + file, + PqSealStore.encode( + persistenceChainId, blockNumber, onchainBlockHash.getBytes(), seals)); + persistedBlock = blockNumber; + persistedHash = onchainBlockHash; + persistedCount = seals.size(); + } catch (final Exception e) { + // A disk fault must not be able to stop this node from taking part in consensus. It costs + // the restart protection, which is what the log line says, and nothing else. + LOG.warn( + "AERE PERSISTENTA-SIGILII: could not write the {} heard seal(s) for block {} to {} " + + "({}). This node keeps working; it just loses its seals if it restarts now.", + seals.size(), + blockNumber, + file, + e.toString()); + } + } + } + + /** + * The seals heard for a block, in canonical order. + * + * @param blockNumber the height of the block the seals must attest + * @param onchainBlockHash the ON-CHAIN hash of that block + * @return the seals, sorted by strictly increasing validator index, possibly empty + */ + /** + * AERE ANCHOR V2: remember the extra (non-Falcon) scheme seals heard on the commits of a block. + * Same key as {@link #record}, same window, same idempotence (first seal per validator and + * scheme wins). NOT persisted across a restart: a node that restarts holds no extras until it + * takes part in one commit, exactly the Falcon situation before PqSealStore, and the producer + * then refuses the v2 certificate for one proposer turn rather than writing a short one. + * + * @param blockNumber the height of the block the seals are over + * @param onchainBlockHash the round-independent on-chain hash of that block + * @param extraSeals the heard scheme seals, unverified + */ + public void recordExtras( + final long blockNumber, final Hash onchainBlockHash, final Collection extraSeals) { + synchronized (this) { + if (onchainBlockHash == null || extraSeals == null || extraSeals.isEmpty()) { + return; + } + final Entry entry = + byHash.computeIfAbsent(onchainBlockHash, unused -> new Entry(blockNumber)); + for (final SchemeSeal seal : extraSeals) { + if (seal == null + || seal.getValidatorIndex() < 0 + || seal.getSignature() == null + || seal.getSchemeWireId() == SealSchemes.FALCON_512.wireId()) { + continue; + } + final long key = ((long) seal.getValidatorIndex() << 8) | (seal.getSchemeWireId() & 0xffL); + entry.extras.putIfAbsent(key, seal); + } + if (blockNumber > highestSeen) { + highestSeen = blockNumber; + } + prune(); + } + } + + /** + * AERE ANCHOR V2: the extra scheme seals heard for a block, canonical order, unverified. + * + * @param blockNumber the height + * @param onchainBlockHash the round-independent on-chain hash + * @return the seals, or empty when nothing was heard under that key + */ + public synchronized List extrasFor( + final long blockNumber, final Hash onchainBlockHash) { + final Entry entry = byHash.get(onchainBlockHash); + if (entry == null || entry.blockNumber != blockNumber) { + return List.of(); + } + final List out = new ArrayList<>(entry.extras.values()); + out.sort(PqAnchorV2.CANONICAL); + return out; + } + + public synchronized List sealsFor( + final long blockNumber, final Hash onchainBlockHash) { + final Entry entry = byHash.get(onchainBlockHash); + if (entry == null || entry.blockNumber != blockNumber) { + return List.of(); + } + return PqAnchor.sortedByIndex(new ArrayList<>(entry.seals.values())); + } + + /** + * How many block hashes are currently retained. + * + * @return the number of entries + */ + public synchronized int entryCount() { + return byHash.size(); + } + + /** + * How many seals are retained for a block hash. + * + * @param onchainBlockHash the ON-CHAIN hash + * @return the seal count, 0 when nothing is retained + */ + public synchronized int sealCount(final Hash onchainBlockHash) { + final Entry entry = byHash.get(onchainBlockHash); + return entry == null ? 0 : entry.seals.size(); + } + + /** + * Drop everything held in memory. For tests, and for a deliberate operator reset. The file, if + * one exists, is deliberately left alone: it is rewritten by the next commit this node hears. + */ + public synchronized void clear() { + byHash.clear(); + highestSeen = -1L; + synchronized (writeLock) { + persistedBlock = -1L; + persistedHash = null; + persistedCount = -1; + } + } + + private void prune() { + final Iterator> it = byHash.entrySet().iterator(); + while (it.hasNext()) { + final Map.Entry e = it.next(); + if (highestSeen - e.getValue().blockNumber > HEIGHT_WINDOW) { + it.remove(); + } + } + while (byHash.size() > MAX_ENTRIES) { + final Iterator oldest = byHash.keySet().iterator(); + oldest.next(); + oldest.remove(); + } + } + + private static final class Entry { + private final long blockNumber; + private final Map seals = new LinkedHashMap<>(); + /** AERE ANCHOR V2: heard extra (non-Falcon) scheme seals, keyed (index << 8 | wire). */ + private final Map extras = new LinkedHashMap<>(); + + private Entry(final long blockNumber) { + this.blockNumber = blockNumber; + } + } +} diff --git a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqSealStore.java b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqSealStore.java new file mode 100755 index 000000000..6427140de --- /dev/null +++ b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqSealStore.java @@ -0,0 +1,401 @@ +/* + * Copyright contributors to Besu / AERE Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +import org.hyperledger.besu.datatypes.Hash; +import org.hyperledger.besu.ethereum.rlp.BytesValueRLPInput; +import org.hyperledger.besu.ethereum.rlp.BytesValueRLPOutput; +import org.hyperledger.besu.ethereum.rlp.RLPInput; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import org.apache.tuweni.bytes.Bytes; +import org.apache.tuweni.bytes.Bytes32; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * AERE PERSISTENTA-SIGILII (2026-08-05): the ON-DISK half of {@link PqSealCache}, holding the Falcon + * seals this node heard for its CHAIN HEAD so that a restart does not start from zero. + * + *

THE SECOND HALF OF A MEASURED CHAIN DEATH. With the anchor armed at K>0, a + * SIMULTANEOUS restart of the whole fleet was measured to kill the chain for good. The first half of + * the deadlock was the registry, which only ever activated while a block was being imported; that is + * repaired (QbftBesuControllerBuilder, marker "AERE BLOCAJ-REPORNIRE") and the seven nodes now report + * "anchor activation at STARTUP from head state: SUCCEEDED". The chain still died, with the refusal + * changing shape only: "registry address-bound=TRUE ... Heard 0 seal(s)". This class is the second + * half. The seals over M(head) travel on nothing but the Commit messages of the head block, which are + * never replayed after a restart, so every node came back holding zero seals, nobody could reach K, + * and nobody could propose. Seals come from Commits, Commits come from proposals, proposals need + * seals - the same circle, one level down. + * + *

WHY THIS IS NOT DEFECT A8 IN ANOTHER COAT, and the distinction is the whole safety + * argument. A8 was a REGISTRY read from a local file and BELIEVED: the public keys that decide + * who is a legitimate signer came out of a file a node could be pointed at wrongly, so the file was + * authority. Nothing here is believed. A Falcon seal is SELF-AUTHENTICATING: {@link + * #readVerified(Path, long, long, Hash, PqSignerRegistry)} re-verifies EVERY seal it reads against + * the anchored registry, over the message M rebuilt from the chain-head header this process just + * loaded, exactly as the producer does at selection time. A forged, edited or replayed file cannot + * inject a seal, because forging one means forging a Falcon-512 signature. The worst a hostile file + * can achieve is what an absent file already achieves: an empty cache, which is today's behaviour. + * + *

WHAT THE FILE IS BOUND TO. The domain label, the chain id, the block NUMBER and the block + * HASH are all in the file and all checked before a single signature is verified. A file from another + * chain, another height, or another fork of the same height is discarded whole. That is not a + * security property (the signature check is), it is an honesty property: it makes "these are the + * seals for THIS head" checkable rather than assumed. + * + *

FAILURE IS NEVER FATAL. Missing, truncated, corrupt, oversized, from another height, or + * unreadable for any reason at all: the answer is an empty list and a log line, never an exception + * that reaches the caller. A node that cannot read its seal file must behave exactly like a node that + * has none, because that is precisely the state this whole class exists to make survivable. + */ +public final class PqSealStore { + + private static final Logger LOG = LoggerFactory.getLogger(PqSealStore.class); + + /** The file name, inside the node's data directory. Never an absolute configured path. */ + public static final String FILE_NAME = "aere-pq-seals.rlp"; + + /** The temporary file the atomic write goes through before the rename. */ + public static final String TEMP_FILE_NAME = "aere-pq-seals.rlp.tmp"; + + /** + * D-330 (2026-09-03): the PREVIOUS slot. The main file holds the highest height heard, which can be + * a proposal that never imported (testnet 28001: the file held 168032 while the head was 168031, + * and after a fleet-wide restart every node had zero seals for the head). The previous slot keeps + * what the main file held before its last rotation, so the head's seals survive one extra height. + */ + public static final String PREVIOUS_FILE_NAME = "aere-pq-seals-anterior.rlp"; + + /** Domain label, so a file written for any other purpose cannot be read as a seal set. */ + public static final String DOMAIN = "AERE-PQ-SEALSTORE-1"; + + private static final Bytes DOMAIN_BYTES = + Bytes.wrap(DOMAIN.getBytes(StandardCharsets.US_ASCII)); + + /** + * Refuse to even parse a file larger than this. A Falcon-512 signature is 666 bytes and a fleet is + * seven, so a real file is about 5 kB; 1 MiB is four orders of magnitude of headroom and still + * bounds what a corrupt or hostile file can make this process allocate. + */ + public static final int MAX_FILE_BYTES = 1024 * 1024; + + /** Refuse to accumulate more seals than this from one file, for the same reason. */ + public static final int MAX_SEALS = 1024; + + /** + * System property forcing an fsync of the temporary file before the rename. Default true: the + * whole point of the file is to survive an unplanned restart, and the measured cost is small + * against the block interval. + */ + public static final String PROPERTY_FSYNC = "aere.pq.sealStore.fsync"; + + private PqSealStore() {} + + /** + * The seal file inside a node's data directory. + * + * @param dataDirectory the node's data directory, as Besu was started with + * @return the path of the seal file + */ + public static Path fileIn(final Path dataDirectory) { + return dataDirectory.resolve(FILE_NAME); + } + + /** + * The previous-slot file beside {@link #fileIn}. + * + * @param dataDirectory the node data directory + * @return the previous slot's path + */ + public static Path previousFileIn(final Path dataDirectory) { + return dataDirectory.resolve(PREVIOUS_FILE_NAME); + } + + /** + * The canonical bytes of a seal set. + * + *

The certificate itself is written by {@link PqAnchor#writeCertificate}, the SAME encoder that + * produces the bytes carried in element 6 of a header and hashed into the anchor digest. One + * logical object, one byte string, no second encoder to drift. + * + * @param chainId the chain id, also part of the M pre-image + * @param blockNumber the height of the block the seals attest + * @param blockHash the ON-CHAIN hash of that block + * @param seals the seals to store, in the order given + * @return the file payload + */ + public static byte[] encode( + final long chainId, + final long blockNumber, + final Bytes blockHash, + final Collection seals) { + final BytesValueRLPOutput out = new BytesValueRLPOutput(); + out.startList(); + out.writeBytes(DOMAIN_BYTES); + out.writeLongScalar(chainId); + out.writeLongScalar(blockNumber); + out.writeBytes(blockHash); + PqAnchor.writeCertificate(out, seals); + out.endList(); + return out.encoded().toArrayUnsafe(); + } + + /** + * Write the payload so that no reader can ever observe a half-written file. + * + *

Temporary file, optional fsync, then an ATOMIC_MOVE onto the target. A process killed at any + * instant leaves either the previous complete file or the new complete file, never a prefix of + * either. The fallback to a non-atomic move exists because ATOMIC_MOVE is not universally + * supported; a filesystem that cannot do it is still better served by a rename than by writing in + * place. + * + * @param file the target file + * @param payload the bytes to write + * @throws IOException if the write or the rename fails + */ + public static void writeAtomically(final Path file, final byte[] payload) throws IOException { + final Path directory = file.toAbsolutePath().getParent(); + if (directory != null) { + Files.createDirectories(directory); + } + final Path temp = + directory == null ? Path.of(TEMP_FILE_NAME) : directory.resolve(TEMP_FILE_NAME); + try (FileChannel channel = + FileChannel.open( + temp, + StandardOpenOption.CREATE, + StandardOpenOption.WRITE, + StandardOpenOption.TRUNCATE_EXISTING)) { + final ByteBuffer buffer = ByteBuffer.wrap(payload); + while (buffer.hasRemaining()) { + channel.write(buffer); + } + if (!"false".equalsIgnoreCase(System.getProperty(PROPERTY_FSYNC))) { + channel.force(true); + } + } + try { + Files.move( + temp, file, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (final UnsupportedOperationException | IOException atomicUnsupported) { + Files.move(temp, file, StandardCopyOption.REPLACE_EXISTING); + } + } + + /** + * Read the seals stored for a block and return ONLY those that verify RIGHT NOW against the + * anchored registry. + * + *

Every seal is put through the same two questions the producer asks at selection time: does + * the index map to a signer in the registry, and does the Falcon signature verify over M(this + * block). Nothing is accepted because it is in the file. What is deliberately NOT re-checked here + * is validator-set eligibility, and that is not a gap: the producer re-checks it against the + * validator set FOR THE PARENT every time it assembles a certificate, so a seal from a former + * validator is dropped there whether it came from a Commit message or from this file. + * + * @param file the seal file + * @param chainId the chain id this node runs, part of the M pre-image + * @param blockNumber the height the seals must attest, normally this node's chain head + * @param blockHash the ON-CHAIN hash the seals must attest + * @param registry the anchored Falcon registry to verify against + * @return the surviving seals sorted by strictly increasing index, empty on any fault whatsoever + */ + public static List readVerified( + final Path file, + final long chainId, + final long blockNumber, + final Hash blockHash, + final PqSignerRegistry registry) { + + if (file == null || registry == null || blockHash == null) { + return List.of(); + } + + final byte[] raw; + try { + if (!Files.isRegularFile(file)) { + LOG.debug("AERE PERSISTENTA-SIGILII: no seal file at {}; starting with an empty cache.", file); + return List.of(); + } + final long size = Files.size(file); + if (size <= 0 || size > MAX_FILE_BYTES) { + LOG.warn( + "AERE PERSISTENTA-SIGILII: seal file {} has an implausible size ({} bytes); IGNORED, " + + "the cache starts empty exactly as it did before this file existed.", + file, + size); + return List.of(); + } + raw = Files.readAllBytes(file); + } catch (final Exception e) { + LOG.warn( + "AERE PERSISTENTA-SIGILII: seal file {} could not be read ({}); IGNORED, the cache starts " + + "empty. A node that cannot read this file must behave like a node that has none.", + file, + e.toString()); + return List.of(); + } + + final List decoded = new ArrayList<>(); + final long storedNumber; + final Bytes storedHash; + try { + final RLPInput in = new BytesValueRLPInput(Bytes.wrap(raw), false); + in.enterList(); + final Bytes domain = in.readBytes(); + if (!DOMAIN_BYTES.equals(domain)) { + LOG.warn( + "AERE PERSISTENTA-SIGILII: seal file {} carries domain {} instead of {}; IGNORED.", + file, + domain, + DOMAIN); + return List.of(); + } + final long storedChainId = in.readLongScalar(); + if (storedChainId != chainId) { + LOG.warn( + "AERE PERSISTENTA-SIGILII: seal file {} was written for chain {} and this node runs " + + "chain {}; IGNORED.", + file, + storedChainId, + chainId); + return List.of(); + } + storedNumber = in.readLongScalar(); + storedHash = in.readBytes(); + in.enterList(); + while (!in.isEndOfCurrentList()) { + if (decoded.size() >= MAX_SEALS) { + LOG.warn( + "AERE PERSISTENTA-SIGILII: seal file {} holds more than {} seals; IGNORED.", + file, + MAX_SEALS); + return List.of(); + } + in.enterList(); + final int index = in.readIntScalar(); + final Bytes signature = in.readBytes(); + in.leaveList(); + decoded.add(new FalconSeal(index, signature)); + } + in.leaveList(); + in.leaveList(); + } catch (final RuntimeException e) { + LOG.warn( + "AERE PERSISTENTA-SIGILII: seal file {} is CORRUPT and did not decode ({}); IGNORED, the " + + "cache starts empty. This costs at most one proposer turn; refusing to start would " + + "cost the chain.", + file, + e.toString()); + return List.of(); + } + + if (storedNumber != blockNumber || !storedHash.equals(blockHash.getBytes())) { + LOG.info( + "AERE PERSISTENTA-SIGILII: seal file {} holds seals for block {} ({}), this node's head is " + + "{} ({}); IGNORED. Seals over another block are of no use to the proposer of the " + + "next one.", + file, + storedNumber, + storedHash, + blockNumber, + blockHash); + return List.of(); + } + + final Bytes32 message = PqAnchor.commitMessage(chainId, blockNumber, blockHash.getBytes()); + final List verified = new ArrayList<>(); + final Set seen = new LinkedHashSet<>(); + int rejectedUnknownIndex = 0; + int rejectedSignature = 0; + for (final FalconSeal seal : PqAnchor.sortedByIndex(decoded)) { + if (seal.getValidatorIndex() < 0 + || seal.getSignature() == null + || !seen.add(seal.getValidatorIndex()) + // D2 (2026-08-06): height-resolved. These seals are over block `blockNumber`, which the + // caller has already matched against the stored header, so the height is known exactly. + // On a restart at the head this resolves to the same registry it always did; the point is + // that it can no longer resolve to a DIFFERENT one without saying so. + // D2 (b-v2): the OWN-HEAD door. The caller has already refused this file unless the + // stored block number and hash equal this node's head, so the subject is this node's + // own head by construction. + || registry.addressForIndexAtOwnHead(blockNumber, seal.getValidatorIndex()) == null) { + rejectedUnknownIndex++; + continue; + } + if (!sealVerifiesAgainstAnchoredRegistry( + registry, blockNumber, message, seal.getValidatorIndex(), seal.getSignature())) { + rejectedSignature++; + continue; + } + verified.add(seal); + } + + LOG.info( + "AERE PERSISTENTA-SIGILII: restored {} of {} stored seal(s) for head {} from {}; {} had no " + + "registry index, {} FAILED Falcon verification over M(head) and were dropped.", + verified.size(), + decoded.size(), + blockNumber, + file, + rejectedUnknownIndex, + rejectedSignature); + return verified; + } + + /** + * THE LOAD-BEARING LINE OF THIS WHOLE FILE, isolated so that removing it is a one-line edit and + * the test suite's negative control can prove that removing it turns the forged-seal test RED. + * + *

This is the same call, on the same registry interface, that {@code PqAnchorProducer.apply} + * makes when it decides which heard seals may enter a certificate. Nothing about a seal is trusted + * because it was on disk. + * + *

D2 (2026-08-06): it now carries the HEIGHT the seals belong to. The adversarial review of + * 2026-08-02 measured that every registry question in this stack was height-less, so a restart + * after a key rotation re-checked seals over an old block against today's keys and dropped them + * all as forged. Here the height is not in doubt: the caller has already refused the file unless + * the stored block number and hash equal this node's head. + * + * @param registry the anchored Falcon registry + * @param blockNumber the height of the block the stored seals attest + * @param message the message M over the block the seals attest + * @param validatorIndex the seal's registry index + * @param signature the Falcon-512 signature bytes + * @return true iff the signature verifies + */ + private static boolean sealVerifiesAgainstAnchoredRegistry( + final PqSignerRegistry registry, + final long blockNumber, + final Bytes32 message, + final int validatorIndex, + final Bytes signature) { + return registry.verifyAtOwnHead(blockNumber, validatorIndex, message, signature); + } +} diff --git a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqSignerRegistry.java b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqSignerRegistry.java new file mode 100755 index 000000000..bfaa45662 --- /dev/null +++ b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqSignerRegistry.java @@ -0,0 +1,167 @@ +/* + * Copyright contributors to Besu / AERE Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +import org.hyperledger.besu.datatypes.Address; + +import org.apache.tuweni.bytes.Bytes; + +/** + * The index-to-signer view the anchor seals rule needs: which validator ADDRESS a registry index + * belongs to, and whether a Falcon signature by that index verifies over a message. + * + *

This exists as an interface for one reason that matters and one that is convenience. The one + * that matters: header validation must be able to state its dependency on the registry explicitly, + * so the registry can be bound to consensus (genesis {@code pqRegistryHash}) rather than picked up + * implicitly from a process-wide singleton whose contents are a local file. The convenience: a rule + * with an injectable registry can be unit-tested without standing up the whole Falcon subsystem. + * + *

The production implementation is {@link #falconSealSupport()}, which resolves {@link + * FalconSealSupport#instance()} LAZILY, per call. Lazily matters: constructing a rule must not + * trigger the Falcon subsystem's start-up guards, because rules are constructed while the protocol + * schedule is being built and the ONE place that deliberately forces those guards early is {@code + * QbftBlockHeaderValidationRulesetFactory}, which does it explicitly. + */ +public interface PqSignerRegistry { + + /** + * D-081 / D2 HARDENING (a). The validator address bound to a registry index AT A HEIGHT. + * + *

WHY THERE IS NO HEIGHT-LESS FORM HERE, and why this is the change and not a nicety. Until + * 2026-08-06 this interface carried BOTH {@code addressForIndex(int)} and a {@code + * addressForIndexAt(long,int)} whose body was {@code default { return addressForIndex(idx); }}. + * That default is precisely what the adversarial review of 2026-08-02 measured as D2: a registry + * has no height argument, so a header that verified yesterday is refused the moment index 0's + * Falcon key is rotated. The default made the defect INVISIBLE TO ITS OWN PROOF - the D2 harness + * (adversar-2026-08-02/harness/RuleProbe.java lines 115-131) injects a registry that overrides + * only the height-less pair, inherits the default, and therefore returns exactly the same verdict + * on repaired and unrepaired code. A probe that cannot go red is not a probe. + * + *

So the height-less pair is DELETED rather than deprecated, and both survivors are abstract. + * The compiler is now the negative control: any implementation, test double included, that cannot + * answer "at which height" fails to compile. The signing side keeps its height-less identity, but + * on {@link FalconSealSupport} and under a name that cannot be mistaken for a verification path - + * see {@code FalconSealSupport.localSigningAddress()}. + * + *

D2 HARDENING (b-v2), 2026-08-06: this is the HISTORY half of the pair. It is reachable + * only from the two header-validation rules, and it REFUSES an unbound height at or above the + * arming height. The own-head half is {@link #addressForIndexAtOwnHead}, which carries the + * measurement that forced the split. + * + * @param blockNumber the height of the header being validated + * @param validatorIndex the index carried by a Falcon seal + * @return the bound address, or null when the index is unregistered at that height, the registry + * active there is not address-bound, or no registry is covered there at all + */ + Address addressForIndexAtHistoric(long blockNumber, int validatorIndex); + + /** + * D2 HARDENING (b-v2). The address bound to a registry index at a height, asked about THIS NODE'S + * OWN HEAD: a block this node is building, or the head it has just restarted onto. + * + *

WHY THIS SECOND NAME EXISTS, and it is a measurement and not a taste. The first shape of + * hardening (b) refused every unbound height at or above the arming height and decided that from + * the block NUMBER alone. On 2026-08-06 that turned six tests red - five in {@code + * PqSealPersistenceTest}, the restart path, and one in {@code PqForkValidatorSetChangeTest}, the + * proposer - and the D078 message states the operational consequence in one line: the node stops + * producing blocks. In all six the number handed to the guard was 1030 with an arming height of + * 1000, which is exactly what a genuinely historical question at the same instant would hand it. + * There is no arithmetic on the height that separates the two. What separates them is WHO SUPPLIES + * THE SUBJECT, and that is known at every call site and was being thrown away at the boundary. + * + *

THIS pair does not refuse for a missing height binding, because at this node's own head the + * head registry IS the answer by construction, and a wrong local acceptance cannot manufacture + * history: the certificate is re-checked by the other six through {@code PqAnchorSealsRule}, on + * the HISTORIC pair, at the parent's height. The cost of a mistake here is one lost round, not a + * branch. It still returns null when a schedule IS configured and covers this height with a + * registry this node does not hold - that is not a missing binding, it is a node running a + * registry the chain has moved off, and that must fail closed on every path. + * + * @param blockNumber this node's own head, or the block it is building + * @param validatorIndex the registry index + * @return the bound address, or null + */ + Address addressForIndexAtOwnHead(long blockNumber, int validatorIndex); + + /** + * D-081 / D2 HARDENING (a). Verify a Falcon signature by a registry index AT A HEIGHT. Must never + * throw. Abstract for the reason given on {@link #addressForIndexAtHistoric}. + * + * @param blockNumber the height of the header carrying the seal + * @param validatorIndex the signer's registry index + * @param message the 32-byte message M that was signed + * @param signature the Falcon-512 signature bytes + * @return true iff a public key exists for the index at that height and the signature verifies + */ + boolean verifyAtHistoric(long blockNumber, int validatorIndex, Bytes message, Bytes signature); + + /** + * D2 HARDENING (b-v2). Verify a Falcon signature over a block THIS NODE holds as its own head or + * is building right now. Never refuses for a missing height binding; see {@link + * #addressForIndexAtOwnHead} for the measurement that forced the split and for what it still does + * refuse. + * + * @param blockNumber this node's own head, or the block it is building + * @param validatorIndex the signer's registry index + * @param message the 32-byte message M that was signed + * @param signature the Falcon-512 signature bytes + * @return true iff a key exists for the index there and the signature verifies + */ + boolean verifyAtOwnHead(long blockNumber, int validatorIndex, Bytes message, Bytes signature); + + /** + * The production registry, backed by the Falcon subsystem singleton and resolved per call. + * + * @return a registry view over {@link FalconSealSupport#instance()} + */ + static PqSignerRegistry falconSealSupport() { + return new PqSignerRegistry() { + @Override + public Address addressForIndexAtHistoric(final long blockNumber, final int validatorIndex) { + return FalconSealSupport.instance().addressForIndexAtHistoric(blockNumber, validatorIndex); + } + + @Override + public boolean verifyAtHistoric( + final long blockNumber, + final int validatorIndex, + final Bytes message, + final Bytes signature) { + return FalconSealSupport.instance() + .verifyAtHistoric(blockNumber, validatorIndex, message, signature); + } + + @Override + public Address addressForIndexAtOwnHead(final long blockNumber, final int validatorIndex) { + return FalconSealSupport.instance().addressForIndexAtOwnHead(blockNumber, validatorIndex); + } + + @Override + public boolean verifyAtOwnHead( + final long blockNumber, + final int validatorIndex, + final Bytes message, + final Bytes signature) { + return FalconSealSupport.instance() + .verifyAtOwnHead(blockNumber, validatorIndex, message, signature); + } + + @Override + public String toString() { + return "FalconSealSupport"; + } + }; + } +} diff --git a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/SchemeSeal.java b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/SchemeSeal.java new file mode 100755 index 000000000..ed5a63807 --- /dev/null +++ b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/SchemeSeal.java @@ -0,0 +1,69 @@ +/* AERE crypto-agility, step 2: a seal that names its scheme. The legacy FalconSeal cannot say + * what mathematics signed it, so a certificate of FalconSeals can never carry a hybrid. This one + * carries the one-byte scheme wire tag from {@link SealSchemes}, which is the whole difference. */ +package org.hyperledger.besu.consensus.common.bft; + +import java.util.Objects; + +import org.apache.tuweni.bytes.Bytes; + +/** One validator seal tagged with the scheme that produced it. Immutable. */ +public final class SchemeSeal { + + private final byte schemeWireId; + private final int validatorIndex; + private final Bytes signature; + + /** @param schemeWireId the {@link SealScheme#wireId()} of the producing scheme + * @param validatorIndex the signer registry index, non-negative + * @param signature the raw signature bytes */ + public SchemeSeal(final byte schemeWireId, final int validatorIndex, final Bytes signature) { + this.schemeWireId = schemeWireId; + this.validatorIndex = validatorIndex; + this.signature = signature; + } + + /** The wire tag of the scheme that produced this seal. */ + public byte getSchemeWireId() { + return schemeWireId; + } + + /** The signer registry index. */ + public int getValidatorIndex() { + return validatorIndex; + } + + /** The raw signature bytes. */ + public Bytes getSignature() { + return signature; + } + + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + if (!(o instanceof SchemeSeal that)) { + return false; + } + return schemeWireId == that.schemeWireId + && validatorIndex == that.validatorIndex + && Objects.equals(signature, that.signature); + } + + @Override + public int hashCode() { + return Objects.hash(schemeWireId, validatorIndex, signature); + } + + @Override + public String toString() { + return "SchemeSeal{scheme=0x" + + Integer.toHexString(schemeWireId & 0xff) + + ", index=" + + validatorIndex + + ", sig=" + + signature.size() + + "B}"; + } +} diff --git a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/SealScheme.java b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/SealScheme.java new file mode 100755 index 000000000..228b9a0f8 --- /dev/null +++ b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/SealScheme.java @@ -0,0 +1,96 @@ +/* + * AERE crypto-agility layer, step 1 (2026-08-24, TOP-3 list item 9). + * + * WHY THIS EXISTS. Until today the anchor certificate code talked to exactly one algorithm, + * Falcon-512, by name: FalconSeal, FalconSealSupport, FalconPublicKeyParameters. "Safe when the + * math changes" was a slogan the code could not honour, because changing the math meant editing + * every call site. This interface is the seam that makes the slogan checkable: the protocol talks + * to a SealScheme; which lattice (or hash) sits behind it is configuration. + * + * WHAT IT DELIBERATELY IS NOT. It does not touch FalconSealSupport yet (that rewiring is step 2, + * and that file is an overwrite-class file under the D-152 patch discipline). It does not load + * private keys from disk (production loading stays per-scheme, exactly as today). It does not + * invent a private-key wire encoding: private keys live only as in-memory handles, so no new + * secret format exists to leak or to get wrong. + */ +package org.hyperledger.besu.consensus.common.bft; + +import java.security.SecureRandom; +import java.util.Optional; + +/** A pluggable post-quantum signature scheme for validator seals. Implementations never throw + * from {@link #verify}: a malformed key or signature is simply an invalid seal. */ +public interface SealScheme { + + /** Stable human-readable identifier, e.g. {@code "falcon-512"}. Matches the naming the chain + * already uses publicly (precompile docs, /v1/pq/verify schemes). */ + String id(); + + /** One-byte wire tag reserved for the versioned certificate format (v2) in which each seal + * names its scheme. 0x00 is reserved for "unversioned legacy Falcon". */ + byte wireId(); + + /** Parse the registry form of a public key (the exact bytes a signer registry stores). + * Empty when the bytes cannot be a key of this scheme. */ + Optional parsePublicKey(byte[] registryForm); + + /** The registry-form length in bytes, so registries can sanity-check entries per scheme. */ + int publicKeyLength(); + + /** Sign a message. Empty on any failure; never throws. */ + Optional sign(PrivateHandle key, byte[] message); + + /** Verify. False on any failure, including a handle from another scheme; never throws. */ + boolean verify(PublicHandle key, byte[] message, byte[] signature); + + /** Convenience: parse-then-verify straight from registry bytes. False on any failure. */ + default boolean verifyRaw(final byte[] registryForm, final byte[] message, final byte[] signature) { + try { + return parsePublicKey(registryForm).map(k -> verify(k, message, signature)).orElse(false); + } catch (final RuntimeException e) { + return false; + } + } + + /** Generate a fresh key pair. Used by test networks only: real validator keys are born in the + * vault ceremony, never inside a node. */ + GeneratedPair generate(SecureRandom random); + + /** + * The scheme's OWN canonical private-key encoding, when it has one. Empty by default. + * + *

DATED NOTE 2026-08-25, refining the sentence at the top of this file. The layer + * still does NOT invent a private-key format: the methods below expose exactly the + * encoding the scheme's library already has, and only schemes that truly have one + * implement them. Measured today on the shipped jar: SLH-DSA-128s has {@code getEncoded()} + * with an exact round-trip, so it implements them; Falcon-512 keeps its key in components + * and its PRODUCTION loading stays untouched in FalconSealSupport, so it does NOT implement + * them and returns empty. Why it was needed: the hybrid producer must be able to receive + * the second scheme's key without every call site knowing which scheme it is. + * + * @param key the private handle + * @return the encoding, or empty when this scheme has no canonical one + */ + default Optional serializePrivateKey(final PrivateHandle key) { + return Optional.empty(); + } + + /** + * Rebuild a private handle from {@link #serializePrivateKey}. Empty on anything unusable. + * + * @param raw the encoding + * @return the handle, or empty + */ + default Optional parsePrivateKey(final byte[] raw) { + return Optional.empty(); + } + + /** Opaque scheme-specific public key. */ + interface PublicHandle {} + + /** Opaque scheme-specific private key. Never serialised by this layer. */ + interface PrivateHandle {} + + /** A freshly generated pair plus the registry form of its public key. */ + record GeneratedPair(PublicHandle publicKey, PrivateHandle privateKey, byte[] publicRegistryForm) {} +} diff --git a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/SealSchemes.java b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/SealSchemes.java new file mode 100755 index 000000000..a3e39f4f2 --- /dev/null +++ b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/SealSchemes.java @@ -0,0 +1,56 @@ +/* AERE crypto-agility: the scheme registry. The protocol asks here by name or wire tag and gets + * an algorithm; swapping the mathematics becomes configuration plus keys, never call-site edits. + * Wire tags are the certificate-v2 vocabulary: 0x00 stays reserved for the unversioned legacy + * Falcon certificate already live on chain 2800, so old certificates can never be confused with + * tagged ones. */ +package org.hyperledger.besu.consensus.common.bft; + +import java.util.List; +import java.util.Optional; + +/** Static registry of the seal schemes this build understands. */ +public final class SealSchemes { + + /** Falcon-512 (lattice), the scheme live on chain 2800 today. */ + public static final SealScheme FALCON_512 = new FalconSealScheme(); + + /** SLH-DSA-128s (hash-based, FIPS 205), the founder-approved hybrid counterpart. */ + public static final SealScheme SLH_DSA_128S = new SlhDsaSealScheme(); + + private static final List ALL = List.of(FALCON_512, SLH_DSA_128S); + + private SealSchemes() {} + + /** All schemes this build understands, in wire-tag order. */ + public static List all() { + return ALL; + } + + /** Look up by stable id, e.g. {@code "falcon-512"}. Empty for unknown ids: an unknown scheme + * must be a loud configuration error at the caller, never a silent default. */ + public static Optional byId(final String id) { + if (id == null) { + return Optional.empty(); + } + // D-325: the SLH-DSA scheme is named "slh-dsa-sha2-128s" on both clients now; the old short id + // is still understood so an existing schedule string keeps meaning the same mathematics. + final String wanted = SlhDsaSealScheme.LEGACY_ID.equals(id) ? SLH_DSA_128S.id() : id; + for (final SealScheme s : ALL) { + if (s.id().equals(wanted)) { + return Optional.of(s); + } + } + return Optional.empty(); + } + + /** Look up by certificate-v2 wire tag. Empty for 0x00 (legacy, not a tagged scheme) and for + * anything unknown. */ + public static Optional byWireId(final byte wireId) { + for (final SealScheme s : ALL) { + if (s.wireId() == wireId) { + return Optional.of(s); + } + } + return Optional.empty(); + } +} diff --git a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/SlhDsaSealScheme.java b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/SlhDsaSealScheme.java new file mode 100755 index 000000000..27a8f8e7e --- /dev/null +++ b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/SlhDsaSealScheme.java @@ -0,0 +1,136 @@ +/* AERE crypto-agility: SLH-DSA-128s (NIST FIPS 205, the standardised SPHINCS+) behind the + * SealScheme seam. This is the second half of the founder-approved hybrid direction of + * 2026-08-07 (option 3): hash-based security alongside lattice-based Falcon, so that a break in + * either mathematics leaves the other standing. The scheme name matches the precompile the chain + * already runs at 0x0AE4 since block 9,189,161, so the public naming stays consistent. + * + * NOTE ON KEYS: introducing this scheme creates NO keys anywhere. Real hybrid validator keys + * require a separate founder-approved ceremony (standing rule, 2026-08-07); test networks + * generate throwaway pairs per run via {@link #generate}. */ +package org.hyperledger.besu.consensus.common.bft; + +import java.security.SecureRandom; +import java.util.Optional; + +import org.bouncycastle.crypto.AsymmetricCipherKeyPair; +import org.hyperledger.besu.consensus.common.bft.slhdsa.SLHDSAKeyGenerationParameters; +import org.hyperledger.besu.consensus.common.bft.slhdsa.SLHDSAKeyPairGenerator; +import org.hyperledger.besu.consensus.common.bft.slhdsa.SLHDSAParameters; +import org.hyperledger.besu.consensus.common.bft.slhdsa.SLHDSAPrivateKeyParameters; +import org.hyperledger.besu.consensus.common.bft.slhdsa.SLHDSAPublicKeyParameters; +import org.hyperledger.besu.consensus.common.bft.slhdsa.SLHDSASigner; + +/** SLH-DSA-128s (small, SHA2 family) as a pluggable seal scheme. */ +/* + * D-337 (2026-09-04): the SLH-DSA classes come from the in-tree copy of Bouncy Castle's engine + * (package ..bft.slhdsa) whose SHA-2 runs on the JDK's SHA-NI intrinsics. Bouncy Castle's pure-Java + * digest made one sha2_128s signature cost 1.4-2.7 s on the validators' CPUs, on the QBFT thread, at + * every anchor parent, and the chain slowed from 0.56 to 0.76 s per block. Measured on the same CPUs + * the in-tree engine signs in 0.3-0.7 s; same keys, same bytes (SlhDsaFastEngineTest pins that + * against the original). + */ +public final class SlhDsaSealScheme implements SealScheme { + + /** Registry form: the encoded SLH-DSA-128s public key (PK.seed || PK.root), 32 bytes. */ + public static final int PUBLIC_KEY_LENGTH = 32; + + private static final SLHDSAParameters PARAMS = SLHDSAParameters.sha2_128s; + + private record Pub(SLHDSAPublicKeyParameters params) implements PublicHandle {} + + private record Priv(SLHDSAPrivateKeyParameters params) implements PrivateHandle {} + + // D-325 (2026-09-03): the id the SECOND client and the public verifier use, "slh-dsa-sha2-128s"; + // the shorter "slh-dsa-128s" stays accepted as an alias in SealSchemes.byId so no schedule breaks. + @Override + public String id() { + return "slh-dsa-sha2-128s"; + } + + /** The alias this build accepted before D-325. */ + public static final String LEGACY_ID = "slh-dsa-128s"; + + @Override + public byte wireId() { + return 0x02; + } + + @Override + public int publicKeyLength() { + return PUBLIC_KEY_LENGTH; + } + + @Override + public Optional parsePublicKey(final byte[] registryForm) { + if (registryForm == null || registryForm.length != PUBLIC_KEY_LENGTH) { + return Optional.empty(); + } + try { + return Optional.of(new Pub(new SLHDSAPublicKeyParameters(PARAMS, registryForm))); + } catch (final RuntimeException e) { + return Optional.empty(); + } + } + + @Override + public Optional sign(final PrivateHandle key, final byte[] message) { + if (!(key instanceof Priv p) || message == null) { + return Optional.empty(); + } + try { + final SLHDSASigner signer = new SLHDSASigner(); + signer.init(true, p.params()); + return Optional.of(signer.generateSignature(message)); + } catch (final RuntimeException e) { + return Optional.empty(); + } + } + + @Override + public boolean verify(final PublicHandle key, final byte[] message, final byte[] signature) { + if (!(key instanceof Pub p) || message == null || signature == null) { + return false; + } + try { + final SLHDSASigner verifier = new SLHDSASigner(); + verifier.init(false, p.params()); + return verifier.verifySignature(message, signature); + } catch (final RuntimeException e) { + return false; + } + } + + @Override + public Optional serializePrivateKey(final PrivateHandle key) { + if (!(key instanceof Priv p)) { + return Optional.empty(); + } + try { + return Optional.ofNullable(p.params().getEncoded()); + } catch (final RuntimeException e) { + return Optional.empty(); + } + } + + @Override + public Optional parsePrivateKey(final byte[] raw) { + if (raw == null || raw.length == 0) { + return Optional.empty(); + } + try { + return Optional.of(new Priv(new SLHDSAPrivateKeyParameters(PARAMS, raw))); + } catch (final RuntimeException e) { + return Optional.empty(); + } + } + + @Override + public GeneratedPair generate(final SecureRandom random) { + final SLHDSAKeyPairGenerator gen = new SLHDSAKeyPairGenerator(); + gen.init(new SLHDSAKeyGenerationParameters(random, PARAMS)); + final AsymmetricCipherKeyPair pair = gen.generateKeyPair(); + final SLHDSAPublicKeyParameters pub = (SLHDSAPublicKeyParameters) pair.getPublic(); + final SLHDSAPrivateKeyParameters priv = (SLHDSAPrivateKeyParameters) pair.getPrivate(); + return new GeneratedPair(new Pub(pub), new Priv(priv), pub.getEncoded()); + } +} diff --git a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/blockcreation/BftBlockCreatorFactory.java b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/blockcreation/BftBlockCreatorFactory.java index 20c072202..1ecdcbf34 100644 --- a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/blockcreation/BftBlockCreatorFactory.java +++ b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/blockcreation/BftBlockCreatorFactory.java @@ -11,6 +11,12 @@ * specific language governing permissions and limitations under the License. * * SPDX-License-Identifier: Apache-2.0 + * + * Modifications Copyright contributors to the Aere Network. + * + * This file was modified by contributors to the Aere Network, as required by section 4(b) of the + * Apache License 2.0. The copyright header above is the upstream one and is left exactly as it was + * found, as section 4(c) requires. The change itself is in patches/0003-aere-pq-anchor.patch. */ package org.hyperledger.besu.consensus.common.bft.blockcreation; @@ -157,6 +163,23 @@ public class BftBlockCreatorFactory { * @return the bytes */ public Bytes createExtraData(final int round, final BlockHeader parentHeader) { + return bftExtraDataCodec.encode(buildExtraData(round, parentHeader)); + } + + /** + * Build the extra data VALUE for a block on this parent, before it is encoded. + * + *

AERE ANCHOR V2: split out of {@link #createExtraData(int, BlockHeader)} with NO behaviour + * change, so a subclass can post-process the value (QBFT rewrites vanityData with the anchor + * digest and fills element 6 with the parent's certificate) without decoding and + * re-encoding bytes it just produced. IBFT, which shares this class, keeps the old path exactly: + * it never overrides this and never sees the anchor. + * + * @param round the round + * @param parentHeader the parent header + * @return the extra data value + */ + protected BftExtraData buildExtraData(final int round, final BlockHeader parentHeader) { final BftContext bftContext = protocolContext.getConsensusContext(BftContext.class); final ValidatorProvider validatorProvider = bftContext.getValidatorProvider(); Optional voteProviderAfterBlock = @@ -177,7 +200,7 @@ public class BftBlockCreatorFactory { round, validators); - return bftExtraDataCodec.encode(extraData); + return extraData; } /** diff --git a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/blockcreation/PqAnchorProducer.java b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/blockcreation/PqAnchorProducer.java new file mode 100755 index 000000000..a46b7d887 --- /dev/null +++ b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/blockcreation/PqAnchorProducer.java @@ -0,0 +1,524 @@ +/* + * Copyright contributors to Besu / AERE Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft.blockcreation; + +import org.apache.tuweni.bytes.Bytes; +import org.hyperledger.besu.datatypes.Hash; +import org.hyperledger.besu.consensus.common.bft.BftContext; +import org.hyperledger.besu.consensus.common.bft.BftExtraData; +import org.hyperledger.besu.consensus.common.bft.FalconSeal; +import org.hyperledger.besu.consensus.common.bft.FalconSealSupport; +import org.hyperledger.besu.consensus.common.bft.HybridSealSupport; +import org.hyperledger.besu.consensus.common.bft.HybridSignerRegistry; +import org.hyperledger.besu.consensus.common.bft.PqAnchor; +import org.hyperledger.besu.consensus.common.bft.PqAnchorConfig; +import org.hyperledger.besu.consensus.common.bft.PqAnchorNotReadyException; +import org.hyperledger.besu.consensus.common.bft.PqAnchorV2; +import org.hyperledger.besu.consensus.common.bft.PqSealCache; +import org.hyperledger.besu.consensus.common.bft.PqSignerRegistry; +import org.hyperledger.besu.consensus.common.bft.SchemeSeal; +import org.hyperledger.besu.consensus.common.bft.SealScheme; +import org.hyperledger.besu.consensus.common.bft.SealSchemes; +import org.hyperledger.besu.datatypes.Address; +import org.hyperledger.besu.ethereum.ProtocolContext; +import org.hyperledger.besu.ethereum.core.BlockHeader; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.apache.tuweni.bytes.Bytes32; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * AERE ANCHOR V2, THE PRODUCER SIDE: the proposer of block N chooses the certificate over + * block N-1 out of what IT heard, writes it into element 6 of N's extraData, and writes the anchor + * digest D of that certificate into all 32 bytes of N's vanityData. + * + *

WHY THE OBJECT MOVED. Option V summarised the certificate sitting in the PARENT's header. + * Measured on the live chain, that object is not unique: over 200 consecutive headers read + * simultaneously from two nodes with identical block hashes, seal ORDER differed on 68 and the seal + * SET differed on 11, because RoundState keeps commits in a LinkedHashMap keyed on author and each + * node writes what IT heard. V2 summarises an object ONE node writes, which reaches everybody as + * the same bytes inside the same proposal. This class is that one node's choice. + * + *

THE ONE PLACE THE CERTIFICATE IS CHOSEN. Which heard seals are eligible, which of them + * actually verify, whether there are enough of them, and what D is, are all decided here. Keeping + * it in one method is not tidiness, it is the correctness argument: the decision "can I propose" + * and the bytes that go into the header are computed by the SAME code on the SAME inputs, so a + * proposal can never be emitted that this node's own arithmetic already rejected. A verdict + * computed by a different path than the run is the exact defect class this project keeps having to + * fix. + * + *

WHAT IS SELECTED, in order, and each step is the mirror of a validator-side rule: + * + *

    + *
  1. every seal heard for the parent, sorted by validator index (rule: strictly increasing); + *
  2. dropped unless the index maps, through the address-bound Falcon registry, to an address in + * the validator set FOR THE PARENT (rule: eligible signer); + *
  3. dropped unless the Falcon signature verifies over M(parent) (rule: k verifications); + *
  4. refused entirely if fewer than K(N) survive (rule: k >= K). + *
+ * + *

SO THE PROPOSER NEVER EMITS A SEAL IT HAS NOT ITSELF VERIFIED. The cost is k Falcon + * verifications per proposal, measured median 0.068 ms and p95 0.249 ms each, so five of them are + * about 1.3 ms against a measured 517 ms block interval. + * + *

THE PROPOSER NEVER COMPARES ANYTHING WITH WHAT ITS PEERS HEARD, and nothing downstream does + * either. That asymmetry is the whole reason V2 works where option V could not: a node that heard + * seven seals accepts a five-seal certificate written by a node that heard five, because Falcon + * verification is public. + */ +public final class PqAnchorProducer { + + private static final Logger LOG = LoggerFactory.getLogger(PqAnchorProducer.class); + + private static final AtomicBoolean LOGGED_ACTIVATION_THRESHOLD = new AtomicBoolean(false); + + private static volatile PqAnchorConfig config; + + private PqAnchorProducer() {} + + /** + * The activation configuration this process produces blocks under. + * + *

Read once from system configuration and memoised. It is deliberately a process-wide value + * rather than an injected one: the two producer entry points that need it (the QBFT block creator + * factory and the sealing path in {@code QbftRound}) sit in different modules with no shared + * construction site, and inventing one would mean threading a new argument through the QBFT round + * factory for no gain. The honest cost is that this cannot be varied per instance inside one JVM, + * which only matters for tests; {@link #useConfigForTesting(PqAnchorConfig)} covers that. + * + * @return the anchor configuration + */ + public static PqAnchorConfig config() { + PqAnchorConfig local = config; + if (local == null) { + synchronized (PqAnchorProducer.class) { + local = config; + if (local == null) { + local = PqAnchorConfig.fromSystemConfiguration(); + warnIfActivationHeightCanRefuse(local); + config = local; + } + } + } + return local; + } + + /** + * Override the memoised configuration. For tests, and for a controller builder that wants to feed + * a genesis-derived configuration in before the first block is produced. + * + * @param replacement the configuration to use, or null to force a re-read + */ + public static void useConfigForTesting(final PqAnchorConfig replacement) { + synchronized (PqAnchorProducer.class) { + config = replacement; + LOGGED_ACTIVATION_THRESHOLD.set(false); + } + } + + /** + * Whether a Falcon seal produced FOR this block must be signed over the V2 message M rather than + * over the legacy ECDSA committed-seal hash. + * + *

ONE BLOCK EARLIER than {@link PqAnchorConfig#activeAt(long)}, and that offset is the whole + * bootstrap of the scheme: the certificate carried by block H is made of seals over block H-1, so + * H-1 must already be sealed in the new form. Every node flips at the same height because the + * height is a pure function of the same configured H. + * + * @param blockNumber the number of the block being sealed + * @return true iff the seal must be over M + */ + public static boolean sealMessageIsAnchorForm(final long blockNumber) { + final PqAnchorConfig cfg = config(); + // DELIBERATELY LEFT UNTOUCHED BY THE INTERVAL, 2026-08-07. With an interval in force, + // seals made at heights that feed no anchor reach no certificate at all, so they are WASTE. + // The temptation is to stop signing them. It is not done here, and the reason is that this + // method decides the FORM of the seal: if the form depended on the interval, two nodes + // reading the interval differently would sign different forms and would no longer recognise + // each other. The form stays a function of H alone, which is the one value the fleet + // coordinates anyway. + // + // The waste remains, and it is BANDWIDTH, not DISK: the seals still travel on the Commit + // messages of every block, they simply no longer reach any header. The disk, which was the + // problem, drops by a factor of . Cutting the pointless signing is done separately, + // at the attachment gate, where it cannot change the form. + return cfg.everActive() && blockNumber + 1L >= cfg.anchorBlock(); + } + + /** + * AERE D-311 (2026-09-02): THE ONE PLACE that says which bytes a commit's post-quantum seal + * signs. Both the emitter ({@code QbftRound.pqSealMessageFor}) and the verifier + * ({@code MessageValidator.SubsequentMessageValidator}, feeding {@code PqCommitEnforcement}) + * call this and nothing else. + * + *

Why it exists, measured on the public testnet 28001 the day it was born: with the anchor + * armed (as on 2800 since 13,014,000) the emitter signed the anchor form, while the commit + * enforcement verified over the ECDSA committed-seal hash. Two copies of one rule, and they + * drifted: at {@code aere.pq.commitPq.forkBlock} every commit of every validator was refused + * ("does NOT verify over the commit digest") and the chain stopped. The rehearsal (F95) never + * saw it because its kit runs without the anchor armed. Arming H1 on 2800 with the old + * verifier would have stopped the live chain. + * + * @param blockNumber the height of the block the commit votes for + * @param onchainHash the ROUND-INDEPENDENT on-chain hash of that block (round forced to 0) + * @param commitHash the ECDSA committed-seal hash (round-specific) + * @return the 32 bytes the Falcon seal (and any hybrid extras) must sign at this height + */ + public static Bytes32 commitSealMessage( + final long blockNumber, final Bytes onchainHash, final Hash commitHash) { + return commitSealMessage(blockNumber, () -> onchainHash, commitHash); + } + + /** + * Same as {@link #commitSealMessage(long, Bytes, Hash)}, with the on-chain hash supplied LAZILY: + * it is only needed (and only computed) in the anchor form. Below the anchor the ECDSA digest is + * the message and no round-0 re-encoding happens, which is also what keeps every upstream test + * that mocks {@code QbftBlockInterface} exactly as it was. + * + * @param blockNumber the height of the block the commit votes for + * @param onchainHash supplier of the ROUND-INDEPENDENT on-chain hash (round forced to 0) + * @param commitHash the ECDSA committed-seal hash (round-specific) + * @return the 32 bytes the Falcon seal (and any hybrid extras) must sign at this height + */ + public static Bytes32 commitSealMessage( + final long blockNumber, + final java.util.function.Supplier onchainHash, + final Hash commitHash) { + if (sealMessageIsAnchorForm(blockNumber)) { + return PqAnchor.commitMessage(config().chainId(), blockNumber, onchainHash.get()); + } + return Bytes32.wrap(commitHash.getBytes()); + } + + /** + * Return the extra data the proposer should encode for a block on top of this parent. + * + *

Below the activation height the input is returned UNCHANGED, so pre-fork behaviour is + * identical byte for byte and the 11.8 million existing blocks stay untouched. The gate is on the + * block NUMBER, never on content. + * + * @param base the extra data the unmodified Besu path would have produced + * @param parentHeader the parent header, whose number and ON-CHAIN hash bind the certificate + * @param protocolContext the protocol context, used only to read the validator set for the parent + * @return either {@code base} itself, or a copy carrying the certificate and the anchor digest + * @throws PqAnchorNotReadyException when the staged threshold cannot be met, meaning this node + * must not propose at all this round + */ + public static BftExtraData apply( + final BftExtraData base, + final BlockHeader parentHeader, + final ProtocolContext protocolContext) { + + final PqAnchorConfig cfg = config(); + final long blockNumber = parentHeader.getNumber() + 1L; + // THE SAME QUESTION BOTH RULES ASK, through the same method: anchorAppliesAt. + // The producer and the validator have to agree on which heights carry a certificate. If the + // producer wrote one where the rule does not ask for it, that would only be waste; if it + // skipped one where the rule DOES ask for it, every block it proposes would be rejected. So + // the same question is asked, not one that merely resembles it. + if (!cfg.anchorAppliesAt(blockNumber)) { + return base; + } + + final long parentNumber = parentHeader.getNumber(); + final Bytes32 message = + PqAnchor.commitMessage(cfg.chainId(), parentNumber, parentHeader.getHash().getBytes()); + + final List heard = + PqSealCache.instance().sealsFor(parentNumber, parentHeader.getHash()); + + final PqSignerRegistry registry = PqSignerRegistry.falconSealSupport(); + final Set

eligible = validatorsForParent(parentHeader, protocolContext); + + final List certificate = new ArrayList<>(); + final Set
seen = new LinkedHashSet<>(); + // Integer.MAX_VALUE means "no cap", i.e. exactly what every node did before 2026-08-07. + final int sealCap = cfg.maxSealsCarried().orElse(Integer.MAX_VALUE); + int rejectedIneligible = 0; + int rejectedInvalid = 0; + // D2 (2026-08-06): the proposer resolves keys AT THE PARENT'S HEIGHT, the same height R2 will + // use when it re-checks this certificate. Before this the producer asked a height-less registry + // while R2 asked a height-resolved one, so at a rotation height the two could disagree about + // which key set applies - the proposer would assemble a certificate the fleet then refuses, and + // the round would fail for a reason no log named. Building and validating now read the same + // question. + for (final FalconSeal seal : PqAnchor.sortedByIndex(heard)) { + // D2 (b-v2): the OWN-HEAD door. parentHeader is this node's own head - this method is + // reached only from the proposer, building the block on top of it. Refusing here for a + // missing schedule is what stopped block production in PqForkValidatorSetChangeTest. + final Address signer = + registry.addressForIndexAtOwnHead(parentNumber, seal.getValidatorIndex()); + if (signer == null || !eligible.contains(signer) || !seen.add(signer)) { + rejectedIneligible++; + continue; + } + if (!registry.verifyAtOwnHead( + parentNumber, seal.getValidatorIndex(), message, seal.getSignature())) { + rejectedInvalid++; + continue; + } + certificate.add(seal); + // THE COST CAP. K is a FLOOR, not a ceiling: without this break the proposer writes every + // eligible seal it happened to hear, so a K=3 chain at N=7 carries four, five, six or seven. + // Measured 2026-08-07 on a live seven-node run with the threshold at 4: 42 blocks carried 4 + // seals, 36 carried 5, 5 carried 6. At 666 bytes a seal that is 200.9 GB per node per year + // instead of 120.5, and the extra buys NOTHING: what a verifier demands is the threshold, not + // how many seals a proposer volunteers above it. + // + // The break is safe precisely because it is placed AFTER the eligibility and signature checks: + // every seal counted here has already been verified, so stopping at the cap can never leave + // the certificate short by including a bad one. And the cap can never be set below the highest + // scheduled K, because PqAnchorConfig.withMaxSealsCarried refuses that at startup. + // + // Unset means no cap, which is exactly the behaviour of every node built before today. + if (certificate.size() >= sealCap) { + break; + } + } + + final int required = cfg.minSealsAt(blockNumber); + if (certificate.size() < required) { + throw new PqAnchorNotReadyException( + parentNumber, + certificate.size(), + required, + "Heard " + + heard.size() + + " seal(s) for parent hash " + + parentHeader.getHash() + + "; " + + rejectedIneligible + + " were not eligible signers (registry address-bound=" + + FalconSealSupport.instance().addressBound() + + ", validators for the parent=" + + eligible.size() + + ") and " + + rejectedInvalid + + " did not verify over M(parent). Schedule=" + + cfg.minSealsSchedule() + + ". A node that has just restarted holds no seals until it takes part in one commit," + + " which costs at most one proposer turn."); + } + + // The list is already ascending by index and de-duplicated by signer address; assert the format + // invariant here rather than trusting the loop, because this is the exact property the digest + // rule and the seals rule both depend on. + if (!PqAnchor.hasStrictlyIncreasingIndices(certificate)) { + throw new IllegalStateException( + "AERE PQ-ANCHOR: assembled a certificate whose indices are not strictly increasing; " + + "refusing to write it. This is a bug in the producer, not a configuration fault."); + } + + if (cfg.anchorV2AppliesAt(blockNumber)) { + return applyV2(cfg, blockNumber, parentHeader, base, certificate, message, required, sealCap); + } + final Bytes32 digest = + PqAnchor.anchorDigest( + cfg.chainId(), parentNumber, parentHeader.getHash().getBytes(), certificate); + + LOG.debug( + "AERE PQ-ANCHOR: block {} carries a {}-seal certificate over parent {} (K={}), vanity D={}", + blockNumber, + certificate.size(), + parentNumber, + required, + digest); + + return new BftExtraData( + digest, + base.getSeals(), + base.getVote(), + base.getRound(), + base.getValidators(), + certificate); + } + + /** + * AERE ANCHOR V2 (2026-09-03): the SCHEME-TAGGED certificate. Carries the Falcon seals already + * verified by the caller, tagged 0x01, plus, for every extra scheme the scheme schedule names at + * the PARENT height, the heard seals of that scheme that (a) come from an index that also holds a + * Falcon seal in this very certificate, the binding that stops a lone hash-based seal from + * standing in for a validator, and (b) verify under the hybrid registry's key for that index and + * scheme, over the SAME message M(parent) the Falcon seal signs. K seals per scheme, capped like + * Falcon. vanityData becomes the v2 digest under "AERE-PQ-ANCHOR-2". Refuses, through the same + * not-ready exception the v1 path uses, rather than writing a certificate short of a scheme. + */ + private static BftExtraData applyV2( + final PqAnchorConfig cfg, + final long blockNumber, + final BlockHeader parentHeader, + final BftExtraData base, + final List falconCertificate, + final Bytes32 message, + final int required, + final int sealCap) { + final long parentNumber = parentHeader.getNumber(); + final List v2 = new ArrayList<>(); + final Set falconIndices = new LinkedHashSet<>(); + for (final FalconSeal f : falconCertificate) { + v2.add(new SchemeSeal(SealSchemes.FALCON_512.wireId(), f.getValidatorIndex(), f.getSignature())); + falconIndices.add(f.getValidatorIndex()); + } + final HybridSealSupport hybrid = HybridSealSupport.instance(); + final Set schemes = + hybrid.schedule().map(sch -> sch.schemesAt(parentNumber)).orElse(Set.of()); + final List heard = + PqSealCache.instance().extrasFor(parentNumber, parentHeader.getHash()); + for (final String schemeId : schemes) { + if (SealSchemes.FALCON_512.id().equals(schemeId)) { + continue; + } + final Optional scheme = SealSchemes.byId(schemeId); + if (scheme.isEmpty()) { + throw new PqAnchorNotReadyException( + parentNumber, + 0, + required, + "the scheme schedule names '" + schemeId + "', which this binary does not implement"); + } + final Optional registry = hybrid.registry(); + if (registry.isEmpty()) { + throw new PqAnchorNotReadyException( + parentNumber, + 0, + required, + "no hybrid registry is loaded on this node, so no " + schemeId + " seal can be verified"); + } + int kept = 0; + int rejected = 0; + for (final SchemeSeal s : heard) { + if (s.getSchemeWireId() != scheme.get().wireId()) { + continue; + } + if (!falconIndices.contains(s.getValidatorIndex())) { + rejected++; + continue; + } + final Optional pk = registry.get().publicKey(s.getValidatorIndex(), schemeId); + if (pk.isEmpty() + || !scheme.get().verifyRaw(pk.get(), message.toArray(), s.getSignature().toArray())) { + rejected++; + continue; + } + v2.add(s); + kept++; + if (kept >= sealCap) { + break; + } + } + if (kept < required) { + throw new PqAnchorNotReadyException( + parentNumber, + kept, + required, + "V2: heard " + + heard.size() + + " extra seal(s) for parent hash " + + parentHeader.getHash() + + "; " + + kept + + " valid " + + schemeId + + " seal(s) bound to a Falcon-certified index, " + + rejected + + " rejected. The v2 certificate needs K=" + + required + + " seals of EVERY scheme the schedule names at height " + + parentNumber + + " (" + + String.join("+", schemes) + + ")."); + } + } + v2.sort(PqAnchorV2.CANONICAL); + final Bytes32 digest = + PqAnchorV2.anchorDigestV2( + cfg.chainId(), parentNumber, parentHeader.getHash().getBytes(), v2); + LOG.debug( + "AERE PQ-ANCHOR V2: block {} carries a {}-seal scheme-tagged certificate over parent {} " + + "(K={} per scheme, schemes={}), vanity D2={}", + blockNumber, + v2.size(), + parentNumber, + required, + schemes, + digest); + return new BftExtraData( + digest, + base.getSeals(), + base.getVote(), + base.getRound(), + base.getValidators(), + List.of(), + v2); + } + + /** + * The validator set FOR the parent block: the nodes that were entitled to seal it. + * + *

Taken for the PARENT, not for the block being built, because those are the nodes whose seals + * a certificate over the parent may contain. On a validator-set change the two sets differ, and + * using the wrong one would either admit a seal the validator side rejects or drop one it + * accepts. + */ + private static Set

validatorsForParent( + final BlockHeader parentHeader, final ProtocolContext protocolContext) { + final Collection
validators = + protocolContext + .getConsensusContext(BftContext.class) + .getValidatorProvider() + .getValidatorsForBlock(parentHeader); + return new LinkedHashSet<>(validators); + } + + /** + * The first stage must not be able to block anything. K at the activation height itself has to be + * 0, because block H's parent predates the fork and may carry no Falcon seals at all: measured, + * the chain has ZERO today, 0 of 46 and 0 of 40 sampled headers, with the RLP element absent + * entirely. A schedule that puts a positive threshold at H would make the proposer of H refuse, + * and every proposer after it, which is a halt at the activation height. This does not throw, it + * shouts: refusing to start on a threshold an operator may have chosen deliberately is not this + * class's decision to make. + */ + private static void warnIfActivationHeightCanRefuse(final PqAnchorConfig cfg) { + if (!cfg.everActive() || !LOGGED_ACTIVATION_THRESHOLD.compareAndSet(false, true)) { + return; + } + final int atActivation = cfg.minSealsAt(cfg.anchorBlock()); + if (atActivation > 0) { + LOG.error( + "AERE PQ-ANCHOR: the staged threshold at the ACTIVATION height {} is {}, not 0. Block H's " + + "parent predates the fork and may carry no Falcon seals at all, so every proposer " + + "at H would refuse and the chain would stop at exactly the height you armed it. " + + "Open the schedule with {}:0 and raise K only at a later step.", + cfg.anchorBlock(), + atActivation, + cfg.anchorBlock()); + } else { + LOG.info( + "AERE PQ-ANCHOR: producer armed. H={}, K(H)=0 as required, schedule={}.", + cfg.anchorBlock(), + cfg.minSealsSchedule()); + } + } +} diff --git a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/ADRS.java b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/ADRS.java new file mode 100755 index 000000000..7610cf876 --- /dev/null +++ b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/ADRS.java @@ -0,0 +1,115 @@ +/* + * Derived from Bouncy Castle 1.80, package org.bouncycastle.pqc.crypto.slhdsa (MIT licence, + * Copyright (c) 2000-2025 The Legion of the Bouncy Castle Inc., see LICENTA-BOUNCYCASTLE.txt), + * regenerated into Aere Network by consensus-pqc/slhdsa-fork/genereaza-motorul.py on 2026-09-04 (D-337). + * The ONLY change of substance: the SHA-2 engine hashes through the JDK's MessageDigest (SHA-NI + * intrinsics) instead of Bouncy Castle's pure-Java digests, which made one SLH-DSA-SHA2-128s signature + * cost seconds on the QBFT thread at every anchor parent. Algorithm, key formats and signature bytes are + * FIPS 205 as before; SlhDsaFastEngineTest pins that against the original, in both directions. + * DO NOT EDIT BY HAND: regenerate with the script above. + */ +package org.hyperledger.besu.consensus.common.bft.slhdsa; + +import org.bouncycastle.util.Arrays; +import org.bouncycastle.util.Pack; + +@SuppressWarnings("all") +class ADRS +{ + static final int WOTS_HASH = 0; + static final int WOTS_PK = 1; + static final int TREE = 2; + static final int FORS_TREE = 3; + static final int FORS_PK = 4; + static final int WOTS_PRF = 5; + static final int FORS_PRF = 6; + + static final int OFFSET_LAYER = 0; + static final int OFFSET_TREE = 4; + static final int OFFSET_TREE_HGT = 24; + static final int OFFSET_TREE_INDEX = 28; + static final int OFFSET_TYPE = 16; + static final int OFFSET_KP_ADDR = 20; + static final int OFFSET_CHAIN_ADDR = 24; + static final int OFFSET_HASH_ADDR = 28; + + final byte[] value = new byte[32]; + + ADRS() + { + } + + ADRS(ADRS adrs) + { + System.arraycopy(adrs.value, 0, this.value, 0, adrs.value.length); + } + + public void setLayerAddress(int layer) + { + Pack.intToBigEndian(layer, value, OFFSET_LAYER); + } + + public int getLayerAddress() + { + return Pack.bigEndianToInt(value, OFFSET_LAYER); + } + + public void setTreeAddress(long tree) + { + // tree address is 12 bytes + Pack.longToBigEndian(tree, value, OFFSET_TREE + 4); + } + + public long getTreeAddress() + { + return Pack.bigEndianToLong(value, OFFSET_TREE + 4); + } + + public void setTreeHeight(int height) + { + Pack.intToBigEndian(height, value, OFFSET_TREE_HGT); + } + + public void setTreeIndex(int index) + { + Pack.intToBigEndian(index, value, OFFSET_TREE_INDEX); + } + + public int getTreeIndex() + { + return Pack.bigEndianToInt(value, OFFSET_TREE_INDEX); + } + + // resets part of value to zero in line with 2.7.3 + public void setTypeAndClear(int type) + { + Pack.intToBigEndian(type, value, OFFSET_TYPE); + + Arrays.fill(value, 20, value.length, (byte)0); + } + + public void changeType(int type) + { + Pack.intToBigEndian(type, value, OFFSET_TYPE); + } + + public void setKeyPairAddress(int keyPairAddr) + { + Pack.intToBigEndian(keyPairAddr, value, OFFSET_KP_ADDR); + } + + public int getKeyPairAddress() + { + return Pack.bigEndianToInt(value, OFFSET_KP_ADDR); + } + + public void setHashAddress(int hashAddr) + { + Pack.intToBigEndian(hashAddr, value, OFFSET_HASH_ADDR); + } + + public void setChainAddress(int chainAddr) + { + Pack.intToBigEndian(chainAddr, value, OFFSET_CHAIN_ADDR); + } +} diff --git a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/Fors.java b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/Fors.java new file mode 100755 index 000000000..d89d83af8 --- /dev/null +++ b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/Fors.java @@ -0,0 +1,178 @@ +/* + * Derived from Bouncy Castle 1.80, package org.bouncycastle.pqc.crypto.slhdsa (MIT licence, + * Copyright (c) 2000-2025 The Legion of the Bouncy Castle Inc., see LICENTA-BOUNCYCASTLE.txt), + * regenerated into Aere Network by consensus-pqc/slhdsa-fork/genereaza-motorul.py on 2026-09-04 (D-337). + * The ONLY change of substance: the SHA-2 engine hashes through the JDK's MessageDigest (SHA-NI + * intrinsics) instead of Bouncy Castle's pure-Java digests, which made one SLH-DSA-SHA2-128s signature + * cost seconds on the QBFT thread at every anchor parent. Algorithm, key formats and signature bytes are + * FIPS 205 as before; SlhDsaFastEngineTest pins that against the original, in both directions. + * DO NOT EDIT BY HAND: regenerate with the script above. + */ +package org.hyperledger.besu.consensus.common.bft.slhdsa; + +import java.math.BigInteger; +import java.util.LinkedList; + +import org.bouncycastle.util.Arrays; + +@SuppressWarnings("all") +class Fors +{ + SLHDSAEngine engine; + + public Fors(SLHDSAEngine engine) + { + this.engine = engine; + } + + // Input: Secret seed SK.seed, start index s, target node height z, public seed PK.seed, address ADRS + // Output: n-byte root node - top node on Stack + byte[] treehash(byte[] skSeed, int s, int z, byte[] pkSeed, ADRS adrsParam) + { + if ((s >>> z) << z != s) + { + return null; + } + + LinkedList stack = new LinkedList(); + ADRS adrs = new ADRS(adrsParam); + + for (int idx = 0; idx < (1 << z); idx++) + { + adrs.setTypeAndClear(ADRS.FORS_PRF); + adrs.setKeyPairAddress(adrsParam.getKeyPairAddress()); + adrs.setTreeHeight(0); + adrs.setTreeIndex(s + idx); + + byte[] sk = engine.PRF(pkSeed, skSeed, adrs); + + adrs.changeType(ADRS.FORS_TREE); + + byte[] node = engine.F(pkSeed, adrs, sk); + + adrs.setTreeHeight(1); + + int adrsTreeHeight = 1; + int adrsTreeIndex = s + idx; + + // while ( Top node on Stack has same height as node ) + while (!stack.isEmpty() && stack.get(0).nodeHeight == adrsTreeHeight) + { + adrsTreeIndex = (adrsTreeIndex - 1) / 2; + adrs.setTreeIndex(adrsTreeIndex); + + NodeEntry current = stack.remove(0); + node = engine.H(pkSeed, adrs, current.nodeValue, node); + + // topmost node is now one layer higher + adrs.setTreeHeight(++adrsTreeHeight); + } + + stack.add(0, new NodeEntry(node, adrsTreeHeight)); + } + + return stack.get(0).nodeValue; + } + + public SIG_FORS[] sign(byte[] md, byte[] skSeed, byte[] pkSeed, ADRS paramAdrs) + { + ADRS adrs = new ADRS(paramAdrs); + +// int[] idxs = message_to_idxs(md, engine.K, engine.A); + int[] idxs = base2B(md, engine.A, engine.K); + SIG_FORS[] sig_fors = new SIG_FORS[engine.K]; +// compute signature elements + int t = engine.T; + for (int i = 0; i < engine.K; i++) + { +// get next index + int idx = idxs[i]; +// pick private key element + adrs.setTypeAndClear(ADRS.FORS_PRF); + adrs.setKeyPairAddress(paramAdrs.getKeyPairAddress()); + adrs.setTreeHeight(0); + adrs.setTreeIndex(i * t + idx); + + byte[] sk = engine.PRF(pkSeed, skSeed, adrs); + + adrs.changeType(ADRS.FORS_TREE); + + byte[][] authPath = new byte[engine.A][]; +// compute auth path + for (int j = 0; j < engine.A; j++) + { + int s = (idx / (1 << j)) ^ 1; + authPath[j] = treehash(skSeed, i * t + s * (1 << j), j, pkSeed, adrs); + } + sig_fors[i] = new SIG_FORS(sk, authPath); + } + return sig_fors; + } + + public byte[] pkFromSig(SIG_FORS[] sig_fors, byte[] message, byte[] pkSeed, ADRS adrs) + { + byte[][] node = new byte[2][]; + byte[][] root = new byte[engine.K][]; + int t = engine.T; + +// int[] idxs = message_to_idxs(message, engine.K, engine.A); + int[] idxs = base2B(message, engine.A, engine.K); + // compute roots + for (int i = 0; i < engine.K; i++) + { + // get next index + int idx = idxs[i]; + // compute leaf + byte[] sk = sig_fors[i].getSK(); + adrs.setTreeHeight(0); + adrs.setTreeIndex(i * t + idx); + node[0] = engine.F(pkSeed, adrs, sk); + // compute root from leaf and AUTH + byte[][] authPath = sig_fors[i].getAuthPath(); + + adrs.setTreeIndex(i * t + idx); + for (int j = 0; j < engine.A; j++) + { + adrs.setTreeHeight(j + 1); + if (((idx / (1 << j)) % 2) == 0) + { + adrs.setTreeIndex(adrs.getTreeIndex() / 2); + node[1] = engine.H(pkSeed, adrs, node[0], authPath[j]); + } + else + { + adrs.setTreeIndex((adrs.getTreeIndex() - 1) / 2); + node[1] = engine.H(pkSeed, adrs, authPath[j], node[0]); + } + node[0] = node[1]; + } + root[i] = node[0]; + } + ADRS forspkADRS = new ADRS(adrs); // copy address to create FTS public key address + forspkADRS.setTypeAndClear(ADRS.FORS_PK); + forspkADRS.setKeyPairAddress(adrs.getKeyPairAddress()); + return engine.T_l(pkSeed, forspkADRS, Arrays.concatenate(root)); + } + + static int[] base2B(byte[] msg, int b, int outLen) + { + int[] baseB = new int[outLen]; + int i = 0; + int bits = 0; + BigInteger total = BigInteger.ZERO; + + for (int o = 0; o < outLen; o++) + { + while (bits < b) + { + total = total.shiftLeft(8).add(BigInteger.valueOf(msg[i] & 0xff)); + i+= 1; + bits += 8; + } + bits -= b; + baseB[o] = (total.shiftRight(bits).mod(BigInteger.valueOf(2).pow(b))).intValue(); + } + + return baseB; + } +} diff --git a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/HT.java b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/HT.java new file mode 100755 index 000000000..b7d96c16f --- /dev/null +++ b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/HT.java @@ -0,0 +1,225 @@ +/* + * Derived from Bouncy Castle 1.80, package org.bouncycastle.pqc.crypto.slhdsa (MIT licence, + * Copyright (c) 2000-2025 The Legion of the Bouncy Castle Inc., see LICENTA-BOUNCYCASTLE.txt), + * regenerated into Aere Network by consensus-pqc/slhdsa-fork/genereaza-motorul.py on 2026-09-04 (D-337). + * The ONLY change of substance: the SHA-2 engine hashes through the JDK's MessageDigest (SHA-NI + * intrinsics) instead of Bouncy Castle's pure-Java digests, which made one SLH-DSA-SHA2-128s signature + * cost seconds on the QBFT thread at every anchor parent. Algorithm, key formats and signature bytes are + * FIPS 205 as before; SlhDsaFastEngineTest pins that against the original, in both directions. + * DO NOT EDIT BY HAND: regenerate with the script above. + */ +package org.hyperledger.besu.consensus.common.bft.slhdsa; + +import java.util.LinkedList; + +import org.bouncycastle.util.Arrays; + +@SuppressWarnings("all") +class HT +{ + private final byte[] skSeed; + private final byte[] pkSeed; + SLHDSAEngine engine; + WotsPlus wots; + + final byte[] htPubKey; + + public HT(SLHDSAEngine engine, byte[] skSeed, byte[] pkSeed) + { + this.skSeed = skSeed; + this.pkSeed = pkSeed; + + this.engine = engine; + this.wots = new WotsPlus(engine); + + ADRS adrs = new ADRS(); + adrs.setLayerAddress(engine.D - 1); + adrs.setTreeAddress(0); + + if (skSeed != null) + { + htPubKey = xmss_PKgen(skSeed, pkSeed, adrs); + } + else + { + htPubKey = null; + } + } + + byte[] sign(byte[] M, long idx_tree, int idx_leaf) + { + // init + ADRS adrs = new ADRS(); + // sign + // adrs.setType(ADRS.TREE); + adrs.setLayerAddress(0); + adrs.setTreeAddress(idx_tree); + SIG_XMSS SIG_tmp = xmss_sign(M, skSeed, idx_leaf, pkSeed, adrs); + SIG_XMSS[] SIG_HT = new SIG_XMSS[engine.D]; + SIG_HT[0] = SIG_tmp; + + adrs.setLayerAddress(0); + adrs.setTreeAddress(idx_tree); + + byte[] root = xmss_pkFromSig(idx_leaf, SIG_tmp, M, pkSeed, adrs); + + for (int j = 1; j < engine.D; j++) + { + idx_leaf = (int)(idx_tree & ((1 << engine.H_PRIME) - 1)); // least significant bits of idx_tree; + idx_tree >>>= engine.H_PRIME; // most significant bits of idx_tree; + adrs.setLayerAddress(j); + adrs.setTreeAddress(idx_tree); + SIG_tmp = xmss_sign(root, skSeed, idx_leaf, pkSeed, adrs); + SIG_HT[j] = SIG_tmp; + if (j < engine.D - 1) + { + root = xmss_pkFromSig(idx_leaf, SIG_tmp, root, pkSeed, adrs); + } + } + + byte[][] totSigs = new byte[SIG_HT.length][]; + for (int i = 0; i != totSigs.length; i++) + { + totSigs[i] = Arrays.concatenate(SIG_HT[i].sig, Arrays.concatenate(SIG_HT[i].auth)); + } + + return Arrays.concatenate(totSigs); + } + + byte[] xmss_PKgen(byte[] skSeed, byte[] pkSeed, ADRS adrs) + { + return treehash(skSeed, 0, engine.H_PRIME, pkSeed, adrs); + } + + // Input: index idx, XMSS signature SIG_XMSS = (sig || AUTH), n-byte message M, public seed PK.seed, address ADRS + // Output: n-byte root value node[0] + byte[] xmss_pkFromSig(int idx, SIG_XMSS sig_xmss, byte[] M, byte[] pkSeed, ADRS paramAdrs) + { + ADRS adrs = new ADRS(paramAdrs); + + // compute WOTS+ pk from WOTS+ sig + adrs.setTypeAndClear(ADRS.WOTS_HASH); + adrs.setKeyPairAddress(idx); + byte[] sig = sig_xmss.getWOTSSig(); + byte[][] AUTH = sig_xmss.getXMSSAUTH(); + + byte[] node0 = wots.pkFromSig(sig, M, pkSeed, adrs); + byte[] node1 = null; + + // compute root from WOTS+ pk and AUTH + adrs.setTypeAndClear(ADRS.TREE); + adrs.setTreeIndex(idx); + for (int k = 0; k < engine.H_PRIME; k++) + { + adrs.setTreeHeight(k + 1); + if (((idx / (1 << k)) % 2) == 0) + { + adrs.setTreeIndex(adrs.getTreeIndex() / 2); + node1 = engine.H(pkSeed, adrs, node0, AUTH[k]); + } + else + { + adrs.setTreeIndex((adrs.getTreeIndex() - 1) / 2); + node1 = engine.H(pkSeed, adrs, AUTH[k], node0); + } + node0 = node1; + } + return node0; + } + + // # Input: n-byte message M, secret seed SK.seed, index idx, public seed PK.seed, + // address ADRS + // # Output: XMSS signature SIG_XMSS = (sig || AUTH) + SIG_XMSS xmss_sign(byte[] M, byte[] skSeed, int idx, byte[] pkSeed, ADRS paramAdrs) + { + byte[][] AUTH = new byte[engine.H_PRIME][]; + + ADRS adrs = new ADRS(paramAdrs); + + adrs.setTypeAndClear(ADRS.TREE); + adrs.setLayerAddress(paramAdrs.getLayerAddress()); + adrs.setTreeAddress(paramAdrs.getTreeAddress()); + + // build authentication path + for (int j = 0; j < engine.H_PRIME; j++) + { + int k = (idx >>> j) ^ 1; + AUTH[j] = treehash(skSeed, k << j, j, pkSeed, adrs); + } + adrs = new ADRS(paramAdrs); + adrs.setTypeAndClear(ADRS.WOTS_HASH); + adrs.setKeyPairAddress(idx); + + byte[] sig = wots.sign(M, skSeed, pkSeed, adrs); + + return new SIG_XMSS(sig, AUTH); + } + + // Input: Secret seed SK.seed, start index s, target node height z, public seed PK.seed, address ADRS + // Output: n-byte root node - top node on Stack + byte[] treehash(byte[] skSeed, int s, int z, byte[] pkSeed, ADRS adrsParam) + { + if ((s >>> z) << z != s) + { + return null; + } + + LinkedList stack = new LinkedList(); + ADRS adrs = new ADRS(adrsParam); + + for (int idx = 0; idx < (1 << z); idx++) + { + adrs.setTypeAndClear(ADRS.WOTS_HASH); + adrs.setKeyPairAddress(s + idx); + byte[] node = wots.pkGen(skSeed, pkSeed, adrs); + + adrs.setTypeAndClear(ADRS.TREE); + adrs.setTreeHeight(1); + adrs.setTreeIndex(s + idx); + + int adrsTreeHeight = 1; + int adrsTreeIndex = s + idx; + + // while ( Top node on Stack has same height as node ) + while (!stack.isEmpty() && stack.get(0).nodeHeight == adrsTreeHeight) + { + adrsTreeIndex = (adrsTreeIndex - 1) / 2; + adrs.setTreeIndex(adrsTreeIndex); + + NodeEntry current = stack.remove(0); + node = engine.H(pkSeed, adrs, current.nodeValue, node); + + // topmost node is now one layer higher + adrs.setTreeHeight(++adrsTreeHeight); + } + + stack.add(0, new NodeEntry(node, adrsTreeHeight)); + } + + return stack.get(0).nodeValue; + } + + // # Input: Message M, signature SIG_HT, public seed PK.seed, tree index idx_tree, +// leaf index idx_leaf, HT public key PK_HT. +// # Output: Boolean + public boolean verify(byte[] M, SIG_XMSS[] sig_ht, byte[] pkSeed, long idx_tree, int idx_leaf, byte[] PK_HT) + { + // init + ADRS adrs = new ADRS(); + // verify + SIG_XMSS SIG_tmp = sig_ht[0]; + adrs.setLayerAddress(0); + adrs.setTreeAddress(idx_tree); + byte[] node = xmss_pkFromSig(idx_leaf, SIG_tmp, M, pkSeed, adrs); + for (int j = 1; j < engine.D; j++) + { + idx_leaf = (int)(idx_tree & ((1 << engine.H_PRIME) - 1)); // least significant bits of idx_tree; + idx_tree >>>= engine.H_PRIME; // most significant bits of idx_tree; + SIG_tmp = sig_ht[j]; + adrs.setLayerAddress(j); + adrs.setTreeAddress(idx_tree); + node = xmss_pkFromSig(idx_leaf, SIG_tmp, node, pkSeed, adrs); + } + return Arrays.areEqual(PK_HT, node); + } +} diff --git a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/IndexedDigest.java b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/IndexedDigest.java new file mode 100755 index 000000000..8ff076d70 --- /dev/null +++ b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/IndexedDigest.java @@ -0,0 +1,26 @@ +/* + * Derived from Bouncy Castle 1.80, package org.bouncycastle.pqc.crypto.slhdsa (MIT licence, + * Copyright (c) 2000-2025 The Legion of the Bouncy Castle Inc., see LICENTA-BOUNCYCASTLE.txt), + * regenerated into Aere Network by consensus-pqc/slhdsa-fork/genereaza-motorul.py on 2026-09-04 (D-337). + * The ONLY change of substance: the SHA-2 engine hashes through the JDK's MessageDigest (SHA-NI + * intrinsics) instead of Bouncy Castle's pure-Java digests, which made one SLH-DSA-SHA2-128s signature + * cost seconds on the QBFT thread at every anchor parent. Algorithm, key formats and signature bytes are + * FIPS 205 as before; SlhDsaFastEngineTest pins that against the original, in both directions. + * DO NOT EDIT BY HAND: regenerate with the script above. + */ +package org.hyperledger.besu.consensus.common.bft.slhdsa; + +@SuppressWarnings("all") +class IndexedDigest +{ + final long idx_tree; + final int idx_leaf; + final byte[] digest; + + IndexedDigest(long idx_tree, int idx_leaf, byte[] digest) + { + this.idx_tree = idx_tree; + this.idx_leaf = idx_leaf; + this.digest = digest; + } +} diff --git a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/JdkDigest.java b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/JdkDigest.java new file mode 100755 index 000000000..c7a8e86fa --- /dev/null +++ b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/JdkDigest.java @@ -0,0 +1,104 @@ +/* + * Derived from Bouncy Castle 1.80, package org.bouncycastle.pqc.crypto.slhdsa (MIT licence, + * Copyright (c) 2000-2025 The Legion of the Bouncy Castle Inc., see LICENTA-BOUNCYCASTLE.txt), + * regenerated into Aere Network by consensus-pqc/slhdsa-fork/genereaza-motorul.py on 2026-09-04 (D-337). + * The ONLY change of substance: the SHA-2 engine hashes through the JDK's MessageDigest (SHA-NI + * intrinsics) instead of Bouncy Castle's pure-Java digests, which made one SLH-DSA-SHA2-128s signature + * cost seconds on the QBFT thread at every anchor parent. Algorithm, key formats and signature bytes are + * FIPS 205 as before; SlhDsaFastEngineTest pins that against the original, in both directions. + * DO NOT EDIT BY HAND: regenerate with the script above. + */ +package org.hyperledger.besu.consensus.common.bft.slhdsa; + +import java.security.DigestException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import org.bouncycastle.crypto.ExtendedDigest; +import org.bouncycastle.util.Memoable; + +/** + * A Bouncy Castle {@link ExtendedDigest} backed by the JDK's {@link MessageDigest}, so SLH-DSA's + * millions of short hashes run on the SHA-NI intrinsics. Measured 2026-09-04 on an EPYC host: 3M + * SHA-256 of 64 bytes, Bouncy Castle 2533 ms, JDK 335 ms; one sha2_128s signature 1364 ms before, + * 325 ms after. {@link Memoable} is how the SLH-DSA engine keeps the pk-seed prefix state: it is + * implemented here with {@link MessageDigest#clone()}, which the SUN provider supports for SHA-2. + */ +@SuppressWarnings("all") +final class JdkDigest implements ExtendedDigest, Memoable { + private final String algorithm; + private final int blockLength; + private MessageDigest md; + + JdkDigest(final String algorithm, final int blockLength) { + this.algorithm = algorithm; + this.blockLength = blockLength; + try { + this.md = MessageDigest.getInstance(algorithm); + } catch (final NoSuchAlgorithmException e) { + throw new IllegalStateException("JDK has no " + algorithm, e); + } + } + + private JdkDigest(final JdkDigest other) { + this.algorithm = other.algorithm; + this.blockLength = other.blockLength; + this.md = cloneOf(other.md); + } + + private static MessageDigest cloneOf(final MessageDigest d) { + try { + return (MessageDigest) d.clone(); + } catch (final CloneNotSupportedException e) { + throw new IllegalStateException(d.getAlgorithm() + " is not cloneable", e); + } + } + + @Override + public String getAlgorithmName() { + return algorithm; + } + + @Override + public int getDigestSize() { + return md.getDigestLength(); + } + + @Override + public int getByteLength() { + return blockLength; + } + + @Override + public void update(final byte in) { + md.update(in); + } + + @Override + public void update(final byte[] in, final int inOff, final int len) { + md.update(in, inOff, len); + } + + @Override + public int doFinal(final byte[] out, final int outOff) { + try { + return md.digest(out, outOff, md.getDigestLength()); + } catch (final DigestException e) { + throw new IllegalStateException(e); + } + } + + @Override + public void reset() { + md.reset(); + } + + @Override + public Memoable copy() { + return new JdkDigest(this); + } + + @Override + public void reset(final Memoable other) { + this.md = cloneOf(((JdkDigest) other).md); + } +} diff --git a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/LICENSE-BouncyCastle.txt b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/LICENSE-BouncyCastle.txt new file mode 100755 index 000000000..bfe226f1d --- /dev/null +++ b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/LICENSE-BouncyCastle.txt @@ -0,0 +1,22 @@ +Licenta sub care ne este ingaduit sa purtam in arborele nostru motorul SLH-DSA generat de +genereaza-motorul.py, adica fisierele din +consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/. +Sursa: bcprov-jdk18on-1.80-sources.jar, sha256 29e8414b7a07060b07222bb786dd594d2e411de0d7723d2fac8adedb0801cbef, +fisierul org/bouncycastle/LICENSE.java. Licenta e MIT, compatibila cu Apache-2.0 a lui Besu. + +Copyright (c) 2000-2025 The Legion of the Bouncy Castle Inc. (https://www.bouncycastle.org) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT +NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT +OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/NodeEntry.java b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/NodeEntry.java new file mode 100755 index 000000000..4cd30cfff --- /dev/null +++ b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/NodeEntry.java @@ -0,0 +1,24 @@ +/* + * Derived from Bouncy Castle 1.80, package org.bouncycastle.pqc.crypto.slhdsa (MIT licence, + * Copyright (c) 2000-2025 The Legion of the Bouncy Castle Inc., see LICENTA-BOUNCYCASTLE.txt), + * regenerated into Aere Network by consensus-pqc/slhdsa-fork/genereaza-motorul.py on 2026-09-04 (D-337). + * The ONLY change of substance: the SHA-2 engine hashes through the JDK's MessageDigest (SHA-NI + * intrinsics) instead of Bouncy Castle's pure-Java digests, which made one SLH-DSA-SHA2-128s signature + * cost seconds on the QBFT thread at every anchor parent. Algorithm, key formats and signature bytes are + * FIPS 205 as before; SlhDsaFastEngineTest pins that against the original, in both directions. + * DO NOT EDIT BY HAND: regenerate with the script above. + */ +package org.hyperledger.besu.consensus.common.bft.slhdsa; + +@SuppressWarnings("all") +class NodeEntry +{ + final byte[] nodeValue; + final int nodeHeight; + + NodeEntry(byte[] nodeValue, int nodeHeight) + { + this.nodeValue = nodeValue; + this.nodeHeight = nodeHeight; + } +} diff --git a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/PK.java b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/PK.java new file mode 100755 index 000000000..4d0ef5fe2 --- /dev/null +++ b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/PK.java @@ -0,0 +1,24 @@ +/* + * Derived from Bouncy Castle 1.80, package org.bouncycastle.pqc.crypto.slhdsa (MIT licence, + * Copyright (c) 2000-2025 The Legion of the Bouncy Castle Inc., see LICENTA-BOUNCYCASTLE.txt), + * regenerated into Aere Network by consensus-pqc/slhdsa-fork/genereaza-motorul.py on 2026-09-04 (D-337). + * The ONLY change of substance: the SHA-2 engine hashes through the JDK's MessageDigest (SHA-NI + * intrinsics) instead of Bouncy Castle's pure-Java digests, which made one SLH-DSA-SHA2-128s signature + * cost seconds on the QBFT thread at every anchor parent. Algorithm, key formats and signature bytes are + * FIPS 205 as before; SlhDsaFastEngineTest pins that against the original, in both directions. + * DO NOT EDIT BY HAND: regenerate with the script above. + */ +package org.hyperledger.besu.consensus.common.bft.slhdsa; + +@SuppressWarnings("all") +class PK +{ + final byte[] seed; + final byte[] root; + + PK(byte[] seed, byte[] root) + { + this.seed = seed; + this.root = root; + } +} diff --git a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/SIG.java b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/SIG.java new file mode 100755 index 000000000..fb6d81702 --- /dev/null +++ b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/SIG.java @@ -0,0 +1,77 @@ +/* + * Derived from Bouncy Castle 1.80, package org.bouncycastle.pqc.crypto.slhdsa (MIT licence, + * Copyright (c) 2000-2025 The Legion of the Bouncy Castle Inc., see LICENTA-BOUNCYCASTLE.txt), + * regenerated into Aere Network by consensus-pqc/slhdsa-fork/genereaza-motorul.py on 2026-09-04 (D-337). + * The ONLY change of substance: the SHA-2 engine hashes through the JDK's MessageDigest (SHA-NI + * intrinsics) instead of Bouncy Castle's pure-Java digests, which made one SLH-DSA-SHA2-128s signature + * cost seconds on the QBFT thread at every anchor parent. Algorithm, key formats and signature bytes are + * FIPS 205 as before; SlhDsaFastEngineTest pins that against the original, in both directions. + * DO NOT EDIT BY HAND: regenerate with the script above. + */ +package org.hyperledger.besu.consensus.common.bft.slhdsa; + +@SuppressWarnings("all") +class SIG +{ + private final byte[] r; + private final SIG_FORS[] sig_fors; + private final SIG_XMSS[] sig_ht; + + public SIG(int n, int k, int a, int d, int hPrime, int wots_len, byte[] signature) + { + this.r = new byte[n]; + System.arraycopy(signature, 0, r, 0, n); + + this.sig_fors = new SIG_FORS[k]; + int offset = n; + for (int i = 0; i != k; i++) + { + byte[] sk = new byte[n]; + System.arraycopy(signature, offset, sk, 0, n); + offset += n; + byte[][] authPath = new byte[a][]; + for (int j = 0; j != a; j++) + { + authPath[j] = new byte[n]; + System.arraycopy(signature, offset, authPath[j], 0, n); + offset += n; + } + sig_fors[i] = new SIG_FORS(sk, authPath); + } + + sig_ht = new SIG_XMSS[d]; + for (int i = 0; i != d; i++) + { + byte[] sig = new byte[wots_len * n]; + System.arraycopy(signature, offset, sig, 0, sig.length); + offset += sig.length; + byte[][] authPath = new byte[hPrime][]; + for (int j = 0; j != hPrime; j++) + { + authPath[j] = new byte[n]; + System.arraycopy(signature, offset, authPath[j], 0, n); + offset += n; + } + sig_ht[i] = new SIG_XMSS(sig, authPath); + } + if (offset != signature.length) + { + throw new IllegalArgumentException("signature wrong length"); + } + } + + public byte[] getR() + { + return r; + } + + public SIG_FORS[] getSIG_FORS() + { + return sig_fors; + } + + public SIG_XMSS[] getSIG_HT() + { + return sig_ht; + } +} diff --git a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/SIG_FORS.java b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/SIG_FORS.java new file mode 100755 index 000000000..7c0064762 --- /dev/null +++ b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/SIG_FORS.java @@ -0,0 +1,34 @@ +/* + * Derived from Bouncy Castle 1.80, package org.bouncycastle.pqc.crypto.slhdsa (MIT licence, + * Copyright (c) 2000-2025 The Legion of the Bouncy Castle Inc., see LICENTA-BOUNCYCASTLE.txt), + * regenerated into Aere Network by consensus-pqc/slhdsa-fork/genereaza-motorul.py on 2026-09-04 (D-337). + * The ONLY change of substance: the SHA-2 engine hashes through the JDK's MessageDigest (SHA-NI + * intrinsics) instead of Bouncy Castle's pure-Java digests, which made one SLH-DSA-SHA2-128s signature + * cost seconds on the QBFT thread at every anchor parent. Algorithm, key formats and signature bytes are + * FIPS 205 as before; SlhDsaFastEngineTest pins that against the original, in both directions. + * DO NOT EDIT BY HAND: regenerate with the script above. + */ +package org.hyperledger.besu.consensus.common.bft.slhdsa; + +@SuppressWarnings("all") +class SIG_FORS +{ + final byte[][] authPath; + final byte[] sk; + + SIG_FORS(byte[] sk, byte[][] authPath) + { + this.authPath = authPath; + this.sk = sk; + } + + byte[] getSK() + { + return sk; + } + + public byte[][] getAuthPath() + { + return authPath; + } +} diff --git a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/SIG_XMSS.java b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/SIG_XMSS.java new file mode 100755 index 000000000..f77ffaf1c --- /dev/null +++ b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/SIG_XMSS.java @@ -0,0 +1,34 @@ +/* + * Derived from Bouncy Castle 1.80, package org.bouncycastle.pqc.crypto.slhdsa (MIT licence, + * Copyright (c) 2000-2025 The Legion of the Bouncy Castle Inc., see LICENTA-BOUNCYCASTLE.txt), + * regenerated into Aere Network by consensus-pqc/slhdsa-fork/genereaza-motorul.py on 2026-09-04 (D-337). + * The ONLY change of substance: the SHA-2 engine hashes through the JDK's MessageDigest (SHA-NI + * intrinsics) instead of Bouncy Castle's pure-Java digests, which made one SLH-DSA-SHA2-128s signature + * cost seconds on the QBFT thread at every anchor parent. Algorithm, key formats and signature bytes are + * FIPS 205 as before; SlhDsaFastEngineTest pins that against the original, in both directions. + * DO NOT EDIT BY HAND: regenerate with the script above. + */ +package org.hyperledger.besu.consensus.common.bft.slhdsa; + +@SuppressWarnings("all") +class SIG_XMSS +{ + final byte[] sig; + final byte[][] auth; + + public SIG_XMSS(byte[] sig, byte[][] auth) + { + this.sig = sig; + this.auth = auth; + } + + public byte[] getWOTSSig() + { + return sig; + } + + public byte[][] getXMSSAUTH() + { + return auth; + } +} diff --git a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/SK.java b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/SK.java new file mode 100755 index 000000000..8040421b1 --- /dev/null +++ b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/SK.java @@ -0,0 +1,24 @@ +/* + * Derived from Bouncy Castle 1.80, package org.bouncycastle.pqc.crypto.slhdsa (MIT licence, + * Copyright (c) 2000-2025 The Legion of the Bouncy Castle Inc., see LICENTA-BOUNCYCASTLE.txt), + * regenerated into Aere Network by consensus-pqc/slhdsa-fork/genereaza-motorul.py on 2026-09-04 (D-337). + * The ONLY change of substance: the SHA-2 engine hashes through the JDK's MessageDigest (SHA-NI + * intrinsics) instead of Bouncy Castle's pure-Java digests, which made one SLH-DSA-SHA2-128s signature + * cost seconds on the QBFT thread at every anchor parent. Algorithm, key formats and signature bytes are + * FIPS 205 as before; SlhDsaFastEngineTest pins that against the original, in both directions. + * DO NOT EDIT BY HAND: regenerate with the script above. + */ +package org.hyperledger.besu.consensus.common.bft.slhdsa; + +@SuppressWarnings("all") +class SK +{ + final byte[] seed; + final byte[] prf; + + SK(byte[] seed, byte[] prf) + { + this.seed = seed; + this.prf = prf; + } +} diff --git a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/SLHDSAEngine.java b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/SLHDSAEngine.java new file mode 100755 index 000000000..bf7b40087 --- /dev/null +++ b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/SLHDSAEngine.java @@ -0,0 +1,466 @@ +/* + * Derived from Bouncy Castle 1.80, package org.bouncycastle.pqc.crypto.slhdsa (MIT licence, + * Copyright (c) 2000-2025 The Legion of the Bouncy Castle Inc., see LICENTA-BOUNCYCASTLE.txt), + * regenerated into Aere Network by consensus-pqc/slhdsa-fork/genereaza-motorul.py on 2026-09-04 (D-337). + * The ONLY change of substance: the SHA-2 engine hashes through the JDK's MessageDigest (SHA-NI + * intrinsics) instead of Bouncy Castle's pure-Java digests, which made one SLH-DSA-SHA2-128s signature + * cost seconds on the QBFT thread at every anchor parent. Algorithm, key formats and signature bytes are + * FIPS 205 as before; SlhDsaFastEngineTest pins that against the original, in both directions. + * DO NOT EDIT BY HAND: regenerate with the script above. + */ +package org.hyperledger.besu.consensus.common.bft.slhdsa; + +import org.bouncycastle.crypto.Digest; +import org.bouncycastle.crypto.Xof; +import org.bouncycastle.crypto.digests.SHAKEDigest; +import org.bouncycastle.crypto.generators.MGF1BytesGenerator; +import org.bouncycastle.crypto.macs.HMac; +import org.bouncycastle.crypto.params.KeyParameter; +import org.bouncycastle.crypto.params.MGFParameters; +import org.bouncycastle.util.Arrays; +import org.bouncycastle.util.Bytes; +import org.bouncycastle.util.Memoable; +import org.bouncycastle.util.Pack; + +@SuppressWarnings("all") +abstract class SLHDSAEngine +{ + final int N; + + final int WOTS_W; + final int WOTS_LOGW; + final int WOTS_LEN; + final int WOTS_LEN1; + final int WOTS_LEN2; + + final int D; + final int A; // FORS_HEIGHT + final int K; // FORS_TREES + final int H; // FULL_HEIGHT + final int H_PRIME; // H / D + + final int T; // T = 1 << A + + public SLHDSAEngine(int n, int w, int d, int a, int k, int h) + { + this.N = n; + + /* SPX_WOTS_LEN2 is floor(log(len_1 * (w - 1)) / log(w)) + 1; we precompute */ + if (w == 16) + { + WOTS_LOGW = 4; + WOTS_LEN1 = (8 * N / WOTS_LOGW); + if (N <= 8) + { + WOTS_LEN2 = 2; + } + else if (N <= 136) + { + WOTS_LEN2 = 3; + } + else if (N <= 256) + { + WOTS_LEN2 = 4; + } + else + { + throw new IllegalArgumentException("cannot precompute SPX_WOTS_LEN2 for n outside {2, .., 256}"); + } + } + else if (w == 256) + { + WOTS_LOGW = 8; + WOTS_LEN1 = (8 * N / WOTS_LOGW); + if (N <= 1) + { + WOTS_LEN2 = 1; + } + else if (N <= 256) + { + WOTS_LEN2 = 2; + } + else + { + throw new IllegalArgumentException("cannot precompute SPX_WOTS_LEN2 for n outside {2, .., 256}"); + } + } + else + { + throw new IllegalArgumentException("wots_w assumed 16 or 256"); + } + this.WOTS_W = w; + this.WOTS_LEN = WOTS_LEN1 + WOTS_LEN2; + + this.D = d; + this.A = a; + this.K = k; + this.H = h; + this.H_PRIME = h / d; + this.T = 1 << a; + } + + abstract void init(byte[] pkSeed); + + abstract byte[] F(byte[] pkSeed, ADRS adrs, byte[] m1); + + abstract byte[] H(byte[] pkSeed, ADRS adrs, byte[] m1, byte[] m2); + + abstract IndexedDigest H_msg(byte[] prf, byte[] pkSeed, byte[] pkRoot, byte[] msgPrefix, byte[] msg); + + abstract byte[] T_l(byte[] pkSeed, ADRS adrs, byte[] m); + + abstract byte[] PRF(byte[] pkSeed, byte[] skSeed, ADRS adrs); + + abstract byte[] PRF_msg(byte[] prf, byte[] randomiser, byte[] msgPrefix, byte[] msg); + + static class Sha2Engine + extends SLHDSAEngine + { + private final HMac treeHMac; + private final MGF1BytesGenerator mgf1; + private final byte[] hmacBuf; + private final Digest msgDigest; + private final byte[] msgDigestBuf; + private final int bl; + private final Digest sha256 = new JdkDigest("SHA-256", 64); + private final byte[] sha256Buf = new byte[sha256.getDigestSize()]; + + private Memoable msgMemo; + private Memoable sha256Memo; + + public Sha2Engine(int n, int w, int d, int a, int k, int h) + { + super(n, w, d, a, k, h); + if (n == 16) + { + this.msgDigest = new JdkDigest("SHA-256", 64); + this.treeHMac = new HMac(new JdkDigest("SHA-256", 64)); + this.mgf1 = new MGF1BytesGenerator(new JdkDigest("SHA-256", 64)); + this.bl = 64; + } + else + { + this.msgDigest = new JdkDigest("SHA-512", 128); + this.treeHMac = new HMac(new JdkDigest("SHA-512", 128)); + this.mgf1 = new MGF1BytesGenerator(new JdkDigest("SHA-512", 128)); + this.bl = 128; + } + + this.hmacBuf = new byte[treeHMac.getMacSize()]; + this.msgDigestBuf = new byte[msgDigest.getDigestSize()]; + } + + void init(byte[] pkSeed) + { + final byte[] padding = new byte[bl]; + + msgDigest.update(pkSeed, 0, pkSeed.length); + msgDigest.update(padding, 0, bl - N); // toByte(0, 64 - n) + msgMemo = ((Memoable)msgDigest).copy(); + + msgDigest.reset(); + + sha256.update(pkSeed, 0, pkSeed.length); + sha256.update(padding, 0, 64 - pkSeed.length); // toByte(0, 64 - n) + sha256Memo = ((Memoable)sha256).copy(); + + sha256.reset(); + } + + public byte[] F(byte[] pkSeed, ADRS adrs, byte[] m1) + { + byte[] compressedADRS = compressedADRS(adrs); + + ((Memoable)sha256).reset(sha256Memo); + + sha256.update(compressedADRS, 0, compressedADRS.length); + sha256.update(m1, 0, m1.length); + sha256.doFinal(sha256Buf, 0); + + return Arrays.copyOfRange(sha256Buf, 0, N); + } + + public byte[] H(byte[] pkSeed, ADRS adrs, byte[] m1, byte[] m2) + { + byte[] compressedADRS = compressedADRS(adrs); + + ((Memoable)msgDigest).reset(msgMemo); + + msgDigest.update(compressedADRS, 0, compressedADRS.length); + + msgDigest.update(m1, 0, m1.length); + msgDigest.update(m2, 0, m2.length); + + msgDigest.doFinal(msgDigestBuf, 0); + + return Arrays.copyOfRange(msgDigestBuf, 0, N); + } + + IndexedDigest H_msg(byte[] prf, byte[] pkSeed, byte[] pkRoot, byte[] msgPrefix, byte[] msg) + { + int forsMsgBytes = ((A * K) + 7) / 8; + int leafBits = H / D; + int treeBits = H - leafBits; + int leafBytes = (leafBits + 7) / 8; + int treeBytes = (treeBits + 7) / 8; + int m = forsMsgBytes + leafBytes + treeBytes; + byte[] out = new byte[m]; + byte[] dig = new byte[msgDigest.getDigestSize()]; + + msgDigest.update(prf, 0, prf.length); + msgDigest.update(pkSeed, 0, pkSeed.length); + msgDigest.update(pkRoot, 0, pkRoot.length); + if (msgPrefix != null) + { + msgDigest.update(msgPrefix, 0, msgPrefix.length); + } + msgDigest.update(msg, 0, msg.length); + msgDigest.doFinal(dig, 0); + + out = bitmask(Arrays.concatenate(prf, pkSeed, dig), out); + + // tree index + // currently, only indexes up to 64 bits are supported + byte[] treeIndexBuf = new byte[8]; + System.arraycopy(out, forsMsgBytes, treeIndexBuf, 8 - treeBytes, treeBytes); + long treeIndex = Pack.bigEndianToLong(treeIndexBuf, 0); + treeIndex &= (~0L) >>> (64 - treeBits); + + byte[] leafIndexBuf = new byte[4]; + System.arraycopy(out, forsMsgBytes + treeBytes, leafIndexBuf, 4 - leafBytes, leafBytes); + + int leafIndex = Pack.bigEndianToInt(leafIndexBuf, 0); + leafIndex &= (~0) >>> (32 - leafBits); + + return new IndexedDigest(treeIndex, leafIndex, Arrays.copyOfRange(out, 0, forsMsgBytes)); + } + + public byte[] T_l(byte[] pkSeed, ADRS adrs, byte[] m) + { + byte[] compressedADRS = compressedADRS(adrs); + + ((Memoable)msgDigest).reset(msgMemo); + + msgDigest.update(compressedADRS, 0, compressedADRS.length); + msgDigest.update(m, 0, m.length); + msgDigest.doFinal(msgDigestBuf, 0); + + return Arrays.copyOfRange(msgDigestBuf, 0, N); + } + + byte[] PRF(byte[] pkSeed, byte[] skSeed, ADRS adrs) + { + int n = skSeed.length; + + ((Memoable)sha256).reset(sha256Memo); + + byte[] compressedADRS = compressedADRS(adrs); + + sha256.update(compressedADRS, 0, compressedADRS.length); + sha256.update(skSeed, 0, skSeed.length); + sha256.doFinal(sha256Buf, 0); + + return Arrays.copyOfRange(sha256Buf, 0, n); + } + + public byte[] PRF_msg(byte[] prf, byte[] randomiser, byte[] msgPrefix, byte[] msg) + { + treeHMac.init(new KeyParameter(prf)); + treeHMac.update(randomiser, 0, randomiser.length); + if (msgPrefix != null) + { + treeHMac.update(msgPrefix, 0, msgPrefix.length); + } + treeHMac.update(msg, 0, msg.length); + treeHMac.doFinal(hmacBuf, 0); + + return Arrays.copyOfRange(hmacBuf, 0, N); + } + + private byte[] compressedADRS(ADRS adrs) + { + byte[] rv = new byte[22]; + System.arraycopy(adrs.value, ADRS.OFFSET_LAYER + 3, rv, 0, 1); // LSB layer address + System.arraycopy(adrs.value, ADRS.OFFSET_TREE + 4, rv, 1, 8); // LS 8 bytes Tree address + System.arraycopy(adrs.value, ADRS.OFFSET_TYPE + 3, rv, 9, 1); // LSB type + System.arraycopy(adrs.value, 20, rv, 10, 12); + + return rv; + } + + protected byte[] bitmask(byte[] key, byte[] m) + { + byte[] mask = new byte[m.length]; + mgf1.init(new MGFParameters(key)); + mgf1.generateBytes(mask, 0, mask.length); + Bytes.xorTo(m.length, m, mask); + return mask; + } + + protected byte[] bitmask(byte[] key, byte[] m1, byte[] m2) + { + byte[] mask = new byte[m1.length + m2.length]; + mgf1.init(new MGFParameters(key)); + mgf1.generateBytes(mask, 0, mask.length); + Bytes.xorTo(m1.length, m1, mask); + Bytes.xorTo(m2.length, m2, 0, mask, m1.length); + return mask; + } + + protected byte[] bitmask256(byte[] key, byte[] m) + { + byte[] mask = new byte[m.length]; + MGF1BytesGenerator mgf1 = new MGF1BytesGenerator(new JdkDigest("SHA-256", 64)); + mgf1.init(new MGFParameters(key)); + mgf1.generateBytes(mask, 0, mask.length); + Bytes.xorTo(m.length, m, mask); + return mask; + } + } + + static class Shake256Engine + extends SLHDSAEngine + { + private final Xof treeDigest; + private final Xof maskDigest; + + public Shake256Engine(int n, int w, int d, int a, int k, int h) + { + super(n, w, d, a, k, h); + + this.treeDigest = new SHAKEDigest(256); + this.maskDigest = new SHAKEDigest(256); + } + + void init(byte[] pkSeed) + { + + } + + byte[] F(byte[] pkSeed, ADRS adrs, byte[] m1) + { + byte[] mTheta = m1; + + byte[] rv = new byte[N]; + + treeDigest.update(pkSeed, 0, pkSeed.length); + treeDigest.update(adrs.value, 0, adrs.value.length); + treeDigest.update(mTheta, 0, mTheta.length); + treeDigest.doFinal(rv, 0, rv.length); + + return rv; + } + + byte[] H(byte[] pkSeed, ADRS adrs, byte[] m1, byte[] m2) + { + byte[] rv = new byte[N]; + + treeDigest.update(pkSeed, 0, pkSeed.length); + treeDigest.update(adrs.value, 0, adrs.value.length); + + treeDigest.update(m1, 0, m1.length); + treeDigest.update(m2, 0, m2.length); + + treeDigest.doFinal(rv, 0, rv.length); + + return rv; + } + + IndexedDigest H_msg(byte[] R, byte[] pkSeed, byte[] pkRoot, byte[] msgPrefix, byte[] msg) + { + int forsMsgBytes = ((A * K) + 7) / 8; + int leafBits = H / D; + int treeBits = H - leafBits; + int leafBytes = (leafBits + 7) / 8; + int treeBytes = (treeBits + 7) / 8; + int m = forsMsgBytes + leafBytes + treeBytes; + byte[] out = new byte[m]; + + treeDigest.update(R, 0, R.length); + treeDigest.update(pkSeed, 0, pkSeed.length); + treeDigest.update(pkRoot, 0, pkRoot.length); + if (msgPrefix != null) + { + treeDigest.update(msgPrefix, 0, msgPrefix.length); + } + treeDigest.update(msg, 0, msg.length); + treeDigest.doFinal(out, 0, out.length); + + // tree index + // currently, only indexes up to 64 bits are supported + byte[] treeIndexBuf = new byte[8]; + System.arraycopy(out, forsMsgBytes, treeIndexBuf, 8 - treeBytes, treeBytes); + long treeIndex = Pack.bigEndianToLong(treeIndexBuf, 0); + treeIndex &= (~0L) >>> (64 - treeBits); + + byte[] leafIndexBuf = new byte[4]; + System.arraycopy(out, forsMsgBytes + treeBytes, leafIndexBuf, 4 - leafBytes, leafBytes); + + int leafIndex = Pack.bigEndianToInt(leafIndexBuf, 0); + leafIndex &= (~0) >>> (32 - leafBits); + + return new IndexedDigest(treeIndex, leafIndex, Arrays.copyOfRange(out, 0, forsMsgBytes)); + } + + byte[] T_l(byte[] pkSeed, ADRS adrs, byte[] m) + { + byte[] mTheta = m; + + byte[] rv = new byte[N]; + + treeDigest.update(pkSeed, 0, pkSeed.length); + treeDigest.update(adrs.value, 0, adrs.value.length); + treeDigest.update(mTheta, 0, mTheta.length); + treeDigest.doFinal(rv, 0, rv.length); + + return rv; + } + + byte[] PRF(byte[] pkSeed, byte[] skSeed, ADRS adrs) + { + treeDigest.update(pkSeed, 0, pkSeed.length); + treeDigest.update(adrs.value, 0, adrs.value.length); + treeDigest.update(skSeed, 0, skSeed.length); + + byte[] prf = new byte[N]; + treeDigest.doFinal(prf, 0, N); + return prf; + } + + public byte[] PRF_msg(byte[] prf, byte[] randomiser, byte[] msgPrefix, byte[] msg) + { + treeDigest.update(prf, 0, prf.length); + treeDigest.update(randomiser, 0, randomiser.length); + if (msgPrefix != null) + { + treeDigest.update(msgPrefix, 0, msgPrefix.length); + } + treeDigest.update(msg, 0, msg.length); + + byte[] out = new byte[N]; + treeDigest.doFinal(out, 0, out.length); + return out; + } + + protected byte[] bitmask(byte[] pkSeed, ADRS adrs, byte[] m) + { + byte[] mask = new byte[m.length]; + maskDigest.update(pkSeed, 0, pkSeed.length); + maskDigest.update(adrs.value, 0, adrs.value.length); + maskDigest.doFinal(mask, 0, mask.length); + Bytes.xorTo(m.length, m, mask); + return mask; + } + + protected byte[] bitmask(byte[] pkSeed, ADRS adrs, byte[] m1, byte[] m2) + { + byte[] mask = new byte[m1.length + m2.length]; + maskDigest.update(pkSeed, 0, pkSeed.length); + maskDigest.update(adrs.value, 0, adrs.value.length); + maskDigest.doFinal(mask, 0, mask.length); + Bytes.xorTo(m1.length, m1, mask); + Bytes.xorTo(m2.length, m2, 0, mask, m1.length); + return mask; + } + } +} diff --git a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/SLHDSAEngineProvider.java b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/SLHDSAEngineProvider.java new file mode 100755 index 000000000..791705d9a --- /dev/null +++ b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/SLHDSAEngineProvider.java @@ -0,0 +1,19 @@ +/* + * Derived from Bouncy Castle 1.80, package org.bouncycastle.pqc.crypto.slhdsa (MIT licence, + * Copyright (c) 2000-2025 The Legion of the Bouncy Castle Inc., see LICENTA-BOUNCYCASTLE.txt), + * regenerated into Aere Network by consensus-pqc/slhdsa-fork/genereaza-motorul.py on 2026-09-04 (D-337). + * The ONLY change of substance: the SHA-2 engine hashes through the JDK's MessageDigest (SHA-NI + * intrinsics) instead of Bouncy Castle's pure-Java digests, which made one SLH-DSA-SHA2-128s signature + * cost seconds on the QBFT thread at every anchor parent. Algorithm, key formats and signature bytes are + * FIPS 205 as before; SlhDsaFastEngineTest pins that against the original, in both directions. + * DO NOT EDIT BY HAND: regenerate with the script above. + */ +package org.hyperledger.besu.consensus.common.bft.slhdsa; + +@SuppressWarnings("all") +interface SLHDSAEngineProvider +{ + int getN(); + + SLHDSAEngine get(); +} diff --git a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/SLHDSAKeyGenerationParameters.java b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/SLHDSAKeyGenerationParameters.java new file mode 100755 index 000000000..b3daec977 --- /dev/null +++ b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/SLHDSAKeyGenerationParameters.java @@ -0,0 +1,33 @@ +/* + * Derived from Bouncy Castle 1.80, package org.bouncycastle.pqc.crypto.slhdsa (MIT licence, + * Copyright (c) 2000-2025 The Legion of the Bouncy Castle Inc., see LICENTA-BOUNCYCASTLE.txt), + * regenerated into Aere Network by consensus-pqc/slhdsa-fork/genereaza-motorul.py on 2026-09-04 (D-337). + * The ONLY change of substance: the SHA-2 engine hashes through the JDK's MessageDigest (SHA-NI + * intrinsics) instead of Bouncy Castle's pure-Java digests, which made one SLH-DSA-SHA2-128s signature + * cost seconds on the QBFT thread at every anchor parent. Algorithm, key formats and signature bytes are + * FIPS 205 as before; SlhDsaFastEngineTest pins that against the original, in both directions. + * DO NOT EDIT BY HAND: regenerate with the script above. + */ +package org.hyperledger.besu.consensus.common.bft.slhdsa; + +import java.security.SecureRandom; + +import org.bouncycastle.crypto.KeyGenerationParameters; + +@SuppressWarnings("all") +public class SLHDSAKeyGenerationParameters + extends KeyGenerationParameters +{ + private final SLHDSAParameters parameters; + + public SLHDSAKeyGenerationParameters(SecureRandom random, SLHDSAParameters parameters) + { + super(random, -1); + this.parameters = parameters; + } + + SLHDSAParameters getParameters() + { + return parameters; + } +} diff --git a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/SLHDSAKeyPairGenerator.java b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/SLHDSAKeyPairGenerator.java new file mode 100755 index 000000000..58a32e8ea --- /dev/null +++ b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/SLHDSAKeyPairGenerator.java @@ -0,0 +1,70 @@ +/* + * Derived from Bouncy Castle 1.80, package org.bouncycastle.pqc.crypto.slhdsa (MIT licence, + * Copyright (c) 2000-2025 The Legion of the Bouncy Castle Inc., see LICENTA-BOUNCYCASTLE.txt), + * regenerated into Aere Network by consensus-pqc/slhdsa-fork/genereaza-motorul.py on 2026-09-04 (D-337). + * The ONLY change of substance: the SHA-2 engine hashes through the JDK's MessageDigest (SHA-NI + * intrinsics) instead of Bouncy Castle's pure-Java digests, which made one SLH-DSA-SHA2-128s signature + * cost seconds on the QBFT thread at every anchor parent. Algorithm, key formats and signature bytes are + * FIPS 205 as before; SlhDsaFastEngineTest pins that against the original, in both directions. + * DO NOT EDIT BY HAND: regenerate with the script above. + */ +package org.hyperledger.besu.consensus.common.bft.slhdsa; + +import java.security.SecureRandom; + +import org.bouncycastle.crypto.AsymmetricCipherKeyPair; +import org.bouncycastle.crypto.AsymmetricCipherKeyPairGenerator; +import org.bouncycastle.crypto.KeyGenerationParameters; + +@SuppressWarnings("all") +public class SLHDSAKeyPairGenerator + implements AsymmetricCipherKeyPairGenerator +{ + private SecureRandom random; + private SLHDSAParameters parameters; + + public void init(KeyGenerationParameters param) + { + random = param.getRandom(); + parameters = ((SLHDSAKeyGenerationParameters)param).getParameters(); + } + + public AsymmetricCipherKeyPair internalGenerateKeyPair(byte[] skSeed, byte[] skPrf, byte[] pkSeed) + { + return implGenerateKeyPair(parameters.getEngine(), skSeed, skPrf, pkSeed); + } + + public AsymmetricCipherKeyPair generateKeyPair() + { + SLHDSAEngine engine = parameters.getEngine(); + + byte[] skSeed = sec_rand(engine.N); + byte[] skPrf = sec_rand(engine.N); + byte[] pkSeed = sec_rand(engine.N); + + return implGenerateKeyPair(engine, skSeed, skPrf, pkSeed); + } + + private AsymmetricCipherKeyPair implGenerateKeyPair(SLHDSAEngine engine, byte[] skSeed, byte[] skPrf, byte[] pkSeed) + { + SK sk = new SK(skSeed, skPrf); + + engine.init(pkSeed); + + // TODO + PK pk = new PK(pkSeed, new HT(engine, sk.seed, pkSeed).htPubKey); + + return new AsymmetricCipherKeyPair( + new SLHDSAPublicKeyParameters(parameters, pk), + new SLHDSAPrivateKeyParameters(parameters, sk, pk)); + } + + private byte[] sec_rand(int n) + { + byte[] rv = new byte[n]; + + random.nextBytes(rv); + + return rv; + } +} diff --git a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/SLHDSAKeyParameters.java b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/SLHDSAKeyParameters.java new file mode 100755 index 000000000..2999c4cac --- /dev/null +++ b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/SLHDSAKeyParameters.java @@ -0,0 +1,31 @@ +/* + * Derived from Bouncy Castle 1.80, package org.bouncycastle.pqc.crypto.slhdsa (MIT licence, + * Copyright (c) 2000-2025 The Legion of the Bouncy Castle Inc., see LICENTA-BOUNCYCASTLE.txt), + * regenerated into Aere Network by consensus-pqc/slhdsa-fork/genereaza-motorul.py on 2026-09-04 (D-337). + * The ONLY change of substance: the SHA-2 engine hashes through the JDK's MessageDigest (SHA-NI + * intrinsics) instead of Bouncy Castle's pure-Java digests, which made one SLH-DSA-SHA2-128s signature + * cost seconds on the QBFT thread at every anchor parent. Algorithm, key formats and signature bytes are + * FIPS 205 as before; SlhDsaFastEngineTest pins that against the original, in both directions. + * DO NOT EDIT BY HAND: regenerate with the script above. + */ +package org.hyperledger.besu.consensus.common.bft.slhdsa; + +import org.bouncycastle.crypto.params.AsymmetricKeyParameter; + +@SuppressWarnings("all") +public class SLHDSAKeyParameters + extends AsymmetricKeyParameter +{ + private final SLHDSAParameters parameters; + + protected SLHDSAKeyParameters(boolean isPrivate, SLHDSAParameters parameters) + { + super(isPrivate); + this.parameters = parameters; + } + + public SLHDSAParameters getParameters() + { + return parameters; + } +} diff --git a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/SLHDSAParameters.java b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/SLHDSAParameters.java new file mode 100755 index 000000000..4a5e422c8 --- /dev/null +++ b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/SLHDSAParameters.java @@ -0,0 +1,186 @@ +/* + * Derived from Bouncy Castle 1.80, package org.bouncycastle.pqc.crypto.slhdsa (MIT licence, + * Copyright (c) 2000-2025 The Legion of the Bouncy Castle Inc., see LICENTA-BOUNCYCASTLE.txt), + * regenerated into Aere Network by consensus-pqc/slhdsa-fork/genereaza-motorul.py on 2026-09-04 (D-337). + * The ONLY change of substance: the SHA-2 engine hashes through the JDK's MessageDigest (SHA-NI + * intrinsics) instead of Bouncy Castle's pure-Java digests, which made one SLH-DSA-SHA2-128s signature + * cost seconds on the QBFT thread at every anchor parent. Algorithm, key formats and signature bytes are + * FIPS 205 as before; SlhDsaFastEngineTest pins that against the original, in both directions. + * DO NOT EDIT BY HAND: regenerate with the script above. + */ +package org.hyperledger.besu.consensus.common.bft.slhdsa; + +@SuppressWarnings("all") +public class SLHDSAParameters +{ + public static final int TYPE_PURE = 0; + public static final int TYPE_SHA2_256 = 1; + public static final int TYPE_SHA2_512 = 2; + public static final int TYPE_SHAKE128 = 3; + public static final int TYPE_SHAKE256 = 4; + + // "Pure" SLH-DSA Parameters + // SHA-2 + public static final SLHDSAParameters sha2_128f = new SLHDSAParameters( + "sha2-128f", new Sha2EngineProvider(16, 16, 22, 6, 33, 66), TYPE_PURE); + public static final SLHDSAParameters sha2_128s = new SLHDSAParameters( + "sha2-128s", new Sha2EngineProvider(16, 16, 7, 12, 14, 63), TYPE_PURE); + + public static final SLHDSAParameters sha2_192f = new SLHDSAParameters( + "sha2-192f", new Sha2EngineProvider(24, 16, 22, 8, 33, 66), TYPE_PURE); + public static final SLHDSAParameters sha2_192s = new SLHDSAParameters( + "sha2-192s", new Sha2EngineProvider(24, 16, 7, 14, 17, 63), TYPE_PURE); + + public static final SLHDSAParameters sha2_256f = new SLHDSAParameters( + "sha2-256f", new Sha2EngineProvider(32, 16, 17, 9, 35, 68), TYPE_PURE); + public static final SLHDSAParameters sha2_256s = new SLHDSAParameters( + "sha2-256s", new Sha2EngineProvider(32, 16, 8, 14, 22, 64), TYPE_PURE); + + // SHAKE-256. + public static final SLHDSAParameters shake_128f = new SLHDSAParameters( + "shake-128f", new Shake256EngineProvider(16, 16, 22, 6, 33, 66), TYPE_PURE); + public static final SLHDSAParameters shake_128s = new SLHDSAParameters( + "shake-128s", new Shake256EngineProvider(16, 16, 7, 12, 14, 63), TYPE_PURE); + + public static final SLHDSAParameters shake_192f = new SLHDSAParameters( + "shake-192f", new Shake256EngineProvider(24, 16, 22, 8, 33, 66), TYPE_PURE); + public static final SLHDSAParameters shake_192s = new SLHDSAParameters( + "shake-192s", new Shake256EngineProvider(24, 16, 7, 14, 17, 63), TYPE_PURE); + + public static final SLHDSAParameters shake_256f = new SLHDSAParameters( + "shake-256f", new Shake256EngineProvider(32, 16, 17, 9, 35, 68), TYPE_PURE); + public static final SLHDSAParameters shake_256s = new SLHDSAParameters( + "shake-256s", new Shake256EngineProvider(32, 16, 8, 14, 22, 64), TYPE_PURE); + + + // "Pre-hash" SLH-DSA Parameters + // SHA-2 + public static final SLHDSAParameters sha2_128f_with_sha256 = new SLHDSAParameters( + "sha2-128f-with-sha256", new Sha2EngineProvider(16, 16, 22, 6, 33, 66), TYPE_SHA2_256); + public static final SLHDSAParameters sha2_128s_with_sha256 = new SLHDSAParameters( + "sha2-128s-with-sha256", new Sha2EngineProvider(16, 16, 7, 12, 14, 63), TYPE_SHA2_256); + + public static final SLHDSAParameters sha2_192f_with_sha512 = new SLHDSAParameters( + "sha2-192f-with-sha512", new Sha2EngineProvider(24, 16, 22, 8, 33, 66), TYPE_SHA2_512); + public static final SLHDSAParameters sha2_192s_with_sha512 = new SLHDSAParameters( + "sha2-192s-with-sha512", new Sha2EngineProvider(24, 16, 7, 14, 17, 63), TYPE_SHA2_512); + + public static final SLHDSAParameters sha2_256f_with_sha512 = new SLHDSAParameters( + "sha2-256f-with-sha512", new Sha2EngineProvider(32, 16, 17, 9, 35, 68), TYPE_SHA2_512); + public static final SLHDSAParameters sha2_256s_with_sha512 = new SLHDSAParameters( + "sha2-256s-with-sha512", new Sha2EngineProvider(32, 16, 8, 14, 22, 64), TYPE_SHA2_512); + + // SHAKE-256. + public static final SLHDSAParameters shake_128f_with_shake128 = new SLHDSAParameters( + "shake-128f-with-shake128", new Shake256EngineProvider(16, 16, 22, 6, 33, 66), TYPE_SHAKE128); + public static final SLHDSAParameters shake_128s_with_shake128 = new SLHDSAParameters( + "shake-128s-with-shake128", new Shake256EngineProvider(16, 16, 7, 12, 14, 63), TYPE_SHAKE128); + + public static final SLHDSAParameters shake_192f_with_shake256 = new SLHDSAParameters( + "shake-192f-with-shake256", new Shake256EngineProvider(24, 16, 22, 8, 33, 66), TYPE_SHAKE256); + public static final SLHDSAParameters shake_192s_with_shake256 = new SLHDSAParameters( + "shake-192s-with-shake256", new Shake256EngineProvider(24, 16, 7, 14, 17, 63), TYPE_SHAKE256); + + public static final SLHDSAParameters shake_256f_with_shake256 = new SLHDSAParameters( + "shake-256f-with-shake256", new Shake256EngineProvider(32, 16, 17, 9, 35, 68), TYPE_SHAKE256); + public static final SLHDSAParameters shake_256s_with_shake256 = new SLHDSAParameters( + "shake-256s-with-shake256", new Shake256EngineProvider(32, 16, 8, 14, 22, 64), TYPE_SHAKE256); + + private final String name; + private final SLHDSAEngineProvider engineProvider; + private final int preHashDigest; + + private SLHDSAParameters(String name, SLHDSAEngineProvider engineProvider, int preHashDigest) + { + this.name = name; + this.engineProvider = engineProvider; + this.preHashDigest = preHashDigest; + } + + public String getName() + { + return name; + } + + public int getType() + { + return preHashDigest; + } + + public int getN() + { + return engineProvider.getN(); + } + + SLHDSAEngine getEngine() + { + return engineProvider.get(); + } + + public boolean isPreHash() + { + return preHashDigest != TYPE_PURE; + } + + private static class Sha2EngineProvider + implements SLHDSAEngineProvider + { + private final int n; + private final int w; + private final int d; + private final int a; + private final int k; + private final int h; + + public Sha2EngineProvider(int n, int w, int d, int a, int k, int h) + { + this.n = n; + this.w = w; + this.d = d; + this.a = a; + this.k = k; + this.h = h; + } + + public int getN() + { + return n; + } + + public SLHDSAEngine get() + { + return new SLHDSAEngine.Sha2Engine(n, w, d, a, k, h); + } + } + + private static class Shake256EngineProvider + implements SLHDSAEngineProvider + { + private final int n; + private final int w; + private final int d; + private final int a; + private final int k; + private final int h; + + public Shake256EngineProvider(int n, int w, int d, int a, int k, int h) + { + this.n = n; + this.w = w; + this.d = d; + this.a = a; + this.k = k; + this.h = h; + } + + public int getN() + { + return n; + } + + public SLHDSAEngine get() + { + return new SLHDSAEngine.Shake256Engine(n, w, d, a, k, h); + } + } +} diff --git a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/SLHDSAPrivateKeyParameters.java b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/SLHDSAPrivateKeyParameters.java new file mode 100755 index 000000000..da9c060bb --- /dev/null +++ b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/SLHDSAPrivateKeyParameters.java @@ -0,0 +1,80 @@ +/* + * Derived from Bouncy Castle 1.80, package org.bouncycastle.pqc.crypto.slhdsa (MIT licence, + * Copyright (c) 2000-2025 The Legion of the Bouncy Castle Inc., see LICENTA-BOUNCYCASTLE.txt), + * regenerated into Aere Network by consensus-pqc/slhdsa-fork/genereaza-motorul.py on 2026-09-04 (D-337). + * The ONLY change of substance: the SHA-2 engine hashes through the JDK's MessageDigest (SHA-NI + * intrinsics) instead of Bouncy Castle's pure-Java digests, which made one SLH-DSA-SHA2-128s signature + * cost seconds on the QBFT thread at every anchor parent. Algorithm, key formats and signature bytes are + * FIPS 205 as before; SlhDsaFastEngineTest pins that against the original, in both directions. + * DO NOT EDIT BY HAND: regenerate with the script above. + */ +package org.hyperledger.besu.consensus.common.bft.slhdsa; + +import org.bouncycastle.util.Arrays; + +@SuppressWarnings("all") +public class SLHDSAPrivateKeyParameters + extends SLHDSAKeyParameters +{ + final SK sk; + final PK pk; + + public SLHDSAPrivateKeyParameters(SLHDSAParameters parameters, byte[] skpkEncoded) + { + super(true, parameters); + int n = parameters.getN(); + if (skpkEncoded.length != 4 * n) + { + throw new IllegalArgumentException("private key encoding does not match parameters"); + } + this.sk = new SK(Arrays.copyOfRange(skpkEncoded, 0, n), Arrays.copyOfRange(skpkEncoded, n, 2 * n)); + this.pk = new PK(Arrays.copyOfRange(skpkEncoded, 2 * n, 3 * n), Arrays.copyOfRange(skpkEncoded, 3 * n, 4 * n)); + } + + public SLHDSAPrivateKeyParameters(SLHDSAParameters parameters, byte[] skSeed, byte[] prf, byte[] pkSeed, byte[] pkRoot) + { + super(true, parameters); + this.sk = new SK(skSeed, prf); + this.pk = new PK(pkSeed, pkRoot); + } + SLHDSAPrivateKeyParameters(SLHDSAParameters parameters, SK sk, PK pk) + { + super(true, parameters); + this.sk = sk; + this.pk = pk; + } + + public byte[] getSeed() + { + return Arrays.clone(sk.seed); + } + + public byte[] getPrf() + { + return Arrays.clone(sk.prf); + } + + public byte[] getPublicSeed() + { + return Arrays.clone(pk.seed); + } + public byte[] getRoot() + { + return Arrays.clone(pk.root); + } + + public byte[] getPublicKey() + { + return Arrays.concatenate(pk.seed, pk.root); + } + + public byte[] getEncoded() + { + return Arrays.concatenate(new byte[][]{ sk.seed, sk.prf, pk.seed, pk.root }); + } + + public byte[] getEncodedPublicKey() + { + return Arrays.concatenate(pk.seed, pk.root); + } +} diff --git a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/SLHDSAPublicKeyParameters.java b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/SLHDSAPublicKeyParameters.java new file mode 100755 index 000000000..544156c66 --- /dev/null +++ b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/SLHDSAPublicKeyParameters.java @@ -0,0 +1,52 @@ +/* + * Derived from Bouncy Castle 1.80, package org.bouncycastle.pqc.crypto.slhdsa (MIT licence, + * Copyright (c) 2000-2025 The Legion of the Bouncy Castle Inc., see LICENTA-BOUNCYCASTLE.txt), + * regenerated into Aere Network by consensus-pqc/slhdsa-fork/genereaza-motorul.py on 2026-09-04 (D-337). + * The ONLY change of substance: the SHA-2 engine hashes through the JDK's MessageDigest (SHA-NI + * intrinsics) instead of Bouncy Castle's pure-Java digests, which made one SLH-DSA-SHA2-128s signature + * cost seconds on the QBFT thread at every anchor parent. Algorithm, key formats and signature bytes are + * FIPS 205 as before; SlhDsaFastEngineTest pins that against the original, in both directions. + * DO NOT EDIT BY HAND: regenerate with the script above. + */ +package org.hyperledger.besu.consensus.common.bft.slhdsa; + +import org.bouncycastle.util.Arrays; + +@SuppressWarnings("all") +public class SLHDSAPublicKeyParameters + extends SLHDSAKeyParameters +{ + private final PK pk; + + public SLHDSAPublicKeyParameters(SLHDSAParameters parameters, byte[] pkValues) + { + super(false, parameters); + int n = parameters.getN(); + if (pkValues.length != 2 * n) + { + throw new IllegalArgumentException("public key encoding does not match parameters"); + } + this.pk = new PK(Arrays.copyOfRange(pkValues, 0, n), Arrays.copyOfRange(pkValues, n, 2 * n)); + } + + SLHDSAPublicKeyParameters(SLHDSAParameters parameters, PK pk) + { + super(false, parameters); + this.pk = pk; + } + + public byte[] getSeed() + { + return Arrays.clone(pk.seed); + } + + public byte[] getRoot() + { + return Arrays.clone(pk.root); + } + + public byte[] getEncoded() + { + return Arrays.concatenate(pk.seed, pk.root); + } +} diff --git a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/SLHDSASigner.java b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/SLHDSASigner.java new file mode 100755 index 000000000..91fdef108 --- /dev/null +++ b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/SLHDSASigner.java @@ -0,0 +1,236 @@ +/* + * Derived from Bouncy Castle 1.80, package org.bouncycastle.pqc.crypto.slhdsa (MIT licence, + * Copyright (c) 2000-2025 The Legion of the Bouncy Castle Inc., see LICENTA-BOUNCYCASTLE.txt), + * regenerated into Aere Network by consensus-pqc/slhdsa-fork/genereaza-motorul.py on 2026-09-04 (D-337). + * The ONLY change of substance: the SHA-2 engine hashes through the JDK's MessageDigest (SHA-NI + * intrinsics) instead of Bouncy Castle's pure-Java digests, which made one SLH-DSA-SHA2-128s signature + * cost seconds on the QBFT thread at every anchor parent. Algorithm, key formats and signature bytes are + * FIPS 205 as before; SlhDsaFastEngineTest pins that against the original, in both directions. + * DO NOT EDIT BY HAND: regenerate with the script above. + */ +package org.hyperledger.besu.consensus.common.bft.slhdsa; + +import java.security.SecureRandom; + +import org.bouncycastle.crypto.CipherParameters; +import org.bouncycastle.crypto.params.ParametersWithContext; +import org.bouncycastle.crypto.params.ParametersWithRandom; +import org.bouncycastle.pqc.crypto.MessageSigner; +import org.bouncycastle.util.Arrays; + +/** + * SLH-DA signer. + *

+ * This version is based on the 3rd submission with deference to the updated reference + * implementation on github as at November 9th 2021. This version includes the changes + * for the countermeasure for the long-message second preimage attack - see + * "https://github.com/sphincs/sphincsplus/commit/61cd2695c6f984b4f4d6ed675378ed9a486cbede" + * for further details. + *

+ */ +@SuppressWarnings("all") +public class SLHDSASigner + implements MessageSigner +{ + private static final byte[] DEFAULT_PREFIX = new byte[]{ 0, 0 }; + + private byte[] msgPrefix; + private SLHDSAPublicKeyParameters pubKey; + private SLHDSAPrivateKeyParameters privKey; + private SecureRandom random; + + /** + * Base constructor. + */ + public SLHDSASigner() + { + } + + public void init(boolean forSigning, CipherParameters param) + { + if (param instanceof ParametersWithContext) + { + ParametersWithContext withContext = (ParametersWithContext)param; + param = withContext.getParameters(); + + int ctxLength = withContext.getContextLength(); + if (ctxLength > 255) + { + throw new IllegalArgumentException("context too long"); + } + + msgPrefix = new byte[2 + ctxLength]; + msgPrefix[0] = 0; + msgPrefix[1] = (byte)ctxLength; + withContext.copyContextTo(msgPrefix, 2, ctxLength); + } + else + { + msgPrefix = DEFAULT_PREFIX; + } + + SLHDSAParameters parameters; + if (forSigning) + { + pubKey = null; + + if (param instanceof ParametersWithRandom) + { + ParametersWithRandom withRandom = (ParametersWithRandom)param; + privKey = (SLHDSAPrivateKeyParameters)withRandom.getParameters(); + random = withRandom.getRandom(); + } + else + { + privKey = (SLHDSAPrivateKeyParameters)param; + random = null; + } + + parameters = privKey.getParameters(); + } + else + { + pubKey = (SLHDSAPublicKeyParameters)param; + privKey = null; + random = null; + + parameters = pubKey.getParameters(); + } + + if (parameters.isPreHash()) + { + throw new IllegalArgumentException("\"pure\" slh-dsa must use non pre-hash parameters"); + } + } + + public byte[] generateSignature(byte[] message) + { + // TODO Redundant with the engine created in internalGenerateSignature + SLHDSAEngine engine = privKey.getParameters().getEngine(); + + engine.init(privKey.pk.seed); + + // generate randomizer + byte[] optRand = new byte[engine.N]; + if (random != null) + { + random.nextBytes(optRand); + } + else + { + System.arraycopy(privKey.pk.seed, 0, optRand, 0, optRand.length); + } + + return internalGenerateSignature(privKey, msgPrefix, message, optRand); + } + + // Equivalent to slh_verify_internal from specs + public boolean verifySignature(byte[] message, byte[] signature) + { + return internalVerifySignature(pubKey, msgPrefix, message, signature); + } + + protected boolean internalVerifySignature(byte[] message, byte[] signature) + { + return internalVerifySignature(pubKey, null, message, signature); + } + + private static boolean internalVerifySignature(SLHDSAPublicKeyParameters pubKey, byte[] msgPrefix, byte[] msg, + byte[] signature) + { + // TODO Check init via pubKey != null + + //# Input: Message M, signature SIG, public key PK + //# Output: Boolean + + // init + SLHDSAEngine engine = pubKey.getParameters().getEngine(); + + engine.init(pubKey.getSeed()); + + ADRS adrs = new ADRS(); + + if (((1 + engine.K * (1 + engine.A) + engine.H + engine.D * engine.WOTS_LEN) * engine.N) != signature.length) + { + return false; + } + + SIG sig = new SIG(engine.N, engine.K, engine.A, engine.D, engine.H_PRIME, engine.WOTS_LEN, signature); + + byte[] R = sig.getR(); + SIG_FORS[] sig_fors = sig.getSIG_FORS(); + SIG_XMSS[] SIG_HT = sig.getSIG_HT(); + + // compute message digest and index + IndexedDigest idxDigest = engine.H_msg(R, pubKey.getSeed(), pubKey.getRoot(), msgPrefix, msg); + byte[] mHash = idxDigest.digest; + long idx_tree = idxDigest.idx_tree; + int idx_leaf = idxDigest.idx_leaf; + + // compute FORS public key + adrs.setTypeAndClear(ADRS.FORS_TREE); + adrs.setLayerAddress(0); + adrs.setTreeAddress(idx_tree); + adrs.setKeyPairAddress(idx_leaf); + byte[] PK_FORS = new Fors(engine).pkFromSig(sig_fors, mHash, pubKey.getSeed(), adrs); + // verify HT signature + adrs.setTypeAndClear(ADRS.TREE); + adrs.setLayerAddress(0); + adrs.setTreeAddress(idx_tree); + adrs.setKeyPairAddress(idx_leaf); + HT ht = new HT(engine, null, pubKey.getSeed()); + return ht.verify(PK_FORS, SIG_HT, pubKey.getSeed(), idx_tree, idx_leaf, pubKey.getRoot()); + } + + protected byte[] internalGenerateSignature(byte[] message, byte[] optRand) + { + return internalGenerateSignature(privKey, null, message, optRand); + } + + private static byte[] internalGenerateSignature(SLHDSAPrivateKeyParameters privKey, byte[] msgPrefix, byte[] msg, + byte[] optRand) + { + // TODO Check init via privKey != null + + SLHDSAEngine engine = privKey.getParameters().getEngine(); + engine.init(privKey.pk.seed); + + Fors fors = new Fors(engine); + byte[] R = engine.PRF_msg(privKey.sk.prf, optRand, msgPrefix, msg); + + IndexedDigest idxDigest = engine.H_msg(R, privKey.pk.seed, privKey.pk.root, msgPrefix, msg); + byte[] mHash = idxDigest.digest; + long idx_tree = idxDigest.idx_tree; + int idx_leaf = idxDigest.idx_leaf; + // FORS sign + ADRS adrs = new ADRS(); + adrs.setTypeAndClear(ADRS.FORS_TREE); + adrs.setTreeAddress(idx_tree); + adrs.setKeyPairAddress(idx_leaf); + SIG_FORS[] sig_fors = fors.sign(mHash, privKey.sk.seed, privKey.pk.seed, adrs); + // get FORS public key - spec shows M? + adrs = new ADRS(); + adrs.setTypeAndClear(ADRS.FORS_TREE); + adrs.setTreeAddress(idx_tree); + adrs.setKeyPairAddress(idx_leaf); + byte[] PK_FORS = fors.pkFromSig(sig_fors, mHash, privKey.pk.seed, adrs); + + // sign FORS public key with HT + ADRS treeAdrs = new ADRS(); + treeAdrs.setTypeAndClear(ADRS.TREE); + + HT ht = new HT(engine, privKey.getSeed(), privKey.getPublicSeed()); + byte[] SIG_HT = ht.sign(PK_FORS, idx_tree, idx_leaf); + + byte[][] sigComponents = new byte[sig_fors.length + 2][]; + sigComponents[0] = R; + + for (int i = 0; i != sig_fors.length; i++) + { + sigComponents[1 + i] = Arrays.concatenate(sig_fors[i].sk, Arrays.concatenate(sig_fors[i].authPath)); + } + sigComponents[sigComponents.length - 1] = SIG_HT; + + return Arrays.concatenate(sigComponents); + } +} diff --git a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/WotsPlus.java b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/WotsPlus.java new file mode 100755 index 000000000..fe1a94e58 --- /dev/null +++ b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/WotsPlus.java @@ -0,0 +1,177 @@ +/* + * Derived from Bouncy Castle 1.80, package org.bouncycastle.pqc.crypto.slhdsa (MIT licence, + * Copyright (c) 2000-2025 The Legion of the Bouncy Castle Inc., see LICENTA-BOUNCYCASTLE.txt), + * regenerated into Aere Network by consensus-pqc/slhdsa-fork/genereaza-motorul.py on 2026-09-04 (D-337). + * The ONLY change of substance: the SHA-2 engine hashes through the JDK's MessageDigest (SHA-NI + * intrinsics) instead of Bouncy Castle's pure-Java digests, which made one SLH-DSA-SHA2-128s signature + * cost seconds on the QBFT thread at every anchor parent. Algorithm, key formats and signature bytes are + * FIPS 205 as before; SlhDsaFastEngineTest pins that against the original, in both directions. + * DO NOT EDIT BY HAND: regenerate with the script above. + */ +package org.hyperledger.besu.consensus.common.bft.slhdsa; + +import org.bouncycastle.util.Arrays; +import org.bouncycastle.util.Pack; + +@SuppressWarnings("all") +class WotsPlus +{ + private final SLHDSAEngine engine; + private final int w; + + WotsPlus(SLHDSAEngine engine) + { + this.engine = engine; + this.w = this.engine.WOTS_W; + } + + byte[] pkGen(byte[] skSeed, byte[] pkSeed, ADRS paramAdrs) + { + ADRS wotspkADRS = new ADRS(paramAdrs); // copy address to create OTS public key address + + byte[][] tmp = new byte[engine.WOTS_LEN][]; + for (int i = 0; i < engine.WOTS_LEN; i++) + { + ADRS adrs = new ADRS(paramAdrs); + adrs.setTypeAndClear(ADRS.WOTS_PRF); + adrs.setKeyPairAddress(paramAdrs.getKeyPairAddress()); + adrs.setChainAddress(i); + adrs.setHashAddress(0); + + byte[] sk = engine.PRF(pkSeed, skSeed, adrs); + + adrs.setTypeAndClear(ADRS.WOTS_HASH); + adrs.setKeyPairAddress(paramAdrs.getKeyPairAddress()); + adrs.setChainAddress(i); + adrs.setHashAddress(0); + tmp[i] = chain(sk, 0, w - 1, pkSeed, adrs); + } + + wotspkADRS.setTypeAndClear(ADRS.WOTS_PK); + wotspkADRS.setKeyPairAddress(paramAdrs.getKeyPairAddress()); + + return engine.T_l(pkSeed, wotspkADRS, Arrays.concatenate(tmp)); + } + + // #Input: Input string X, start index i, number of steps s, public seed PK.seed, address ADRS + // #Output: value of F iterated s times on X + byte[] chain(byte[] X, int i, int s, byte[] pkSeed, ADRS adrs) + { + if (s == 0) + { + return Arrays.clone(X); + } + if ((i + s) > (this.w - 1)) + { + return null; + } + byte[] result = X; + for (int j = 0; j < s; ++j) + { + adrs.setHashAddress(i + j); + result = engine.F(pkSeed, adrs, result); + } + return result; + } + + // #Input: Message M, secret seed SK.seed, public seed PK.seed, address ADRS + // #Output: WOTS+ signature sig + public byte[] sign(byte[] M, byte[] skSeed, byte[] pkSeed, ADRS paramAdrs) + { + ADRS adrs = new ADRS(paramAdrs); + + int[] msg = new int[engine.WOTS_LEN]; + + // convert message to base w + base_w(M, 0, w, msg, 0, engine.WOTS_LEN1); + + // compute checksum + int csum = 0; + for (int i = 0; i < engine.WOTS_LEN1; i++) + { + csum += w - 1 - msg[i]; + } + + // convert csum to base w + if ((engine.WOTS_LOGW % 8) != 0) + { + csum = csum << (8 - ((engine.WOTS_LEN2 * engine.WOTS_LOGW) % 8)); + } + int len_2_bytes = (engine.WOTS_LEN2 * engine.WOTS_LOGW + 7) / 8; + byte[] csum_bytes = Pack.intToBigEndian(csum); + base_w(csum_bytes, 4 - len_2_bytes, w, msg, engine.WOTS_LEN1, engine.WOTS_LEN2); + + byte[][] sig = new byte[engine.WOTS_LEN][]; + for (int i = 0; i < engine.WOTS_LEN; i++) + { + adrs.setTypeAndClear(ADRS.WOTS_PRF); + adrs.setKeyPairAddress(paramAdrs.getKeyPairAddress()); + adrs.setChainAddress(i); + adrs.setHashAddress(0); + byte[] sk = engine.PRF(pkSeed, skSeed, adrs); + adrs.setTypeAndClear(ADRS.WOTS_HASH); + adrs.setKeyPairAddress(paramAdrs.getKeyPairAddress()); + adrs.setChainAddress(i); + adrs.setHashAddress(0); + sig[i] = chain(sk, 0, msg[i], pkSeed, adrs); + } + return Arrays.concatenate(sig); + } + + // + // Input: len_X-byte string X, int w, output length out_len + // Output: out_len int array basew + void base_w(byte[] X, int XOff, int w, int[] output, int outOff, int outLen) + { + int total = 0; + int bits = 0; + + for (int consumed = 0; consumed < outLen; consumed++) + { + if (bits == 0) + { + total = X[XOff++]; + bits += 8; + } + bits -= engine.WOTS_LOGW; + output[outOff++] = ((total >>> bits) & (w - 1)); + } + } + + public byte[] pkFromSig(byte[] sig, byte[] M, byte[] pkSeed, ADRS adrs) + { + ADRS wotspkADRS = new ADRS(adrs); + + int[] msg = new int[engine.WOTS_LEN]; + + // convert message to base w + base_w(M, 0, w, msg, 0, engine.WOTS_LEN1); + + // compute checksum + int csum = 0; + for (int i = 0; i < engine.WOTS_LEN1; i++ ) + { + csum += w - 1 - msg[i]; + } + + // convert csum to base w + csum = csum << (8 - ((engine.WOTS_LEN2 * engine.WOTS_LOGW) % 8)); + int len_2_bytes = (engine.WOTS_LEN2 * engine.WOTS_LOGW + 7) / 8; + byte[] csum_bytes = Pack.intToBigEndian(csum); + base_w(csum_bytes, 4 - len_2_bytes, w, msg, engine.WOTS_LEN1, engine.WOTS_LEN2); + + byte[] sigI = new byte[engine.N]; + byte[][] tmp = new byte[engine.WOTS_LEN][]; + for (int i = 0; i < engine.WOTS_LEN; i++ ) + { + adrs.setChainAddress(i); + System.arraycopy(sig, i * engine.N, sigI, 0, engine.N); + tmp[i] = chain(sigI, msg[i], w - 1 - msg[i], pkSeed, adrs); + } + + wotspkADRS.setTypeAndClear(ADRS.WOTS_PK); + wotspkADRS.setKeyPairAddress(adrs.getKeyPairAddress()); + + return engine.T_l(pkSeed, wotspkADRS, Arrays.concatenate(tmp)); + } +} diff --git a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/tools/PqRegistryHashTool.java b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/tools/PqRegistryHashTool.java new file mode 100755 index 000000000..a9e7a2a48 --- /dev/null +++ b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/tools/PqRegistryHashTool.java @@ -0,0 +1,231 @@ +/* + * Copyright contributors to Besu. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft.tools; + +import java.nio.file.Path; +import java.nio.file.Paths; + +import org.hyperledger.besu.consensus.common.bft.PqRegistryHash; + +/** + * AERE A8: compute the canonical hash of a Falcon validator registry so it can be put into genesis + * as {@code config.pqRegistryHash}. + * + *

This tool calls THE SAME code the startup guard calls. That is the whole point of it existing + * as a class inside {@code consensus:common} rather than as a shell script: a tool that computed the + * hash its own way would sooner or later disagree with the guard, and the operator would be told a + * number that does not let any node start. + * + *

+ *   java -cp <besu lib> org.hyperledger.besu.consensus.common.bft.tools.PqRegistryHashTool \
+ *        <registry-file> [--chain-id N] [--block H] [--quiet]
+ * 
+ * + *

The registry file may be a legacy {@code .properties} registry, a bare JSON manifest, or a + * genesis file carrying {@code config.aereFalconRegistry}; the format is sniffed. + */ +public final class PqRegistryHashTool { + + private PqRegistryHashTool() {} + + /** + * Entry point. + * + * @param args registry file, then optional {@code --chain-id N}, {@code --block H}, {@code + * --quiet} + */ + public static void main(final String[] args) { + String file = null; + long chainId = 2800L; + long block = 0L; + boolean quiet = false; + boolean chainIdGiven = false; + + for (int i = 0; i < args.length; i++) { + switch (args[i]) { + case "--chain-id": + chainId = Long.parseLong(require(args, ++i, "--chain-id")); + chainIdGiven = true; + break; + case "--block": + block = Long.parseLong(require(args, ++i, "--block")); + break; + case "--quiet": + quiet = true; + break; + case "-h": + case "--help": + usage(System.out); + return; + default: + if (args[i].startsWith("--")) { + System.err.println("unknown option: " + args[i]); + usage(System.err); + System.exit(2); + } + file = args[i]; + } + } + if (file == null) { + usage(System.err); + System.exit(2); + return; + } + + final Path path = Paths.get(file); + final PqRegistryHash.Registry reg; + try { + reg = PqRegistryHash.loadAuto(path); + } catch (final PqRegistryHash.RegistryConfigException e) { + System.err.println(e.getMessage()); + System.exit(3); + return; + } + + // AERE D-C (2026-08-06). hashFor, not hashV1, and this is the line an operator pastes into + // genesis. For a proof-bound (v2) registry the node's gate compares hashV2; printing hashV1 here + // gives the whole fleet a value NOTHING on a node ever computes. Measured on a network of seven + // on 2026-08-06: all seven start, all seven report the registry loaded, and the chain stops at + // H-1. A wrong instruction is more dangerous than a missing one. + // + // hashV1 is also useless as an epoch identifier, which is the other reason it cannot merely be + // kept alongside: the bind height is not in the v1 pre-image, so two rotation epochs of the same + // fleet produce the SAME v1 number. + final String canonical = PqRegistryHash.hashFor(reg, chainId); + + if (quiet) { + System.out.println("0x" + canonical); + return; + } + + System.out.println("AERE PQC A8 - canonical Falcon registry hash"); + System.out.println(" file : " + reg.sourcePath()); + System.out.println(" source kind : " + reg.kind()); + System.out.println(" entries : " + reg.count()); + System.out.println(" address-bound : " + reg.addressBound()); + System.out.println(" chainId : " + chainId + (chainIdGiven ? "" : " (default)")); + System.out.println( + " format : " + + (reg.proofBound() + ? "v2, proof-bound, bindHeight=" + reg.bindHeight() + : "v1, no binding proofs")); + System.out.println(); + System.out.println( + " pqRegistryHash (canonical " + + (reg.proofBound() ? "v2" : "v1") + + ", THIS is the value for genesis):"); + System.out.println(" 0x" + canonical); + if (reg.proofBound()) { + System.out.println(); + System.out.println( + " AERE D-B: schedule this registry at block " + + reg.bindHeight() + + " AND NOWHERE ELSE. Every row's possession proof and validator claim sign that"); + System.out.println( + " height; a node handed this file at any other block refuses to start with"); + System.out.println(" AERE-PQC-REG-BIND-08."); + } + System.out.println(); + System.out.println(" legacy v0 concat hash (only for cross-checking the genesis alloc anchor"); + System.out.println(" slot that FalconSealSupport.parseManifest already computes; NOT the value"); + System.out.println(" pqRegistryHash is compared against):"); + System.out.println(" 0x" + PqRegistryHash.hashV0Legacy(reg)); + System.out.println(); + System.out.println(" per-index fingerprints, keccak256(addr||pk) first 8 hex."); + System.out.println(" Paste these next to another node's to find a differing row; they are"); + System.out.println(" digests, so they carry no key material:"); + for (final PqRegistryHash.Entry e : reg.entries()) { + final byte[] a = e.address(); + System.out.println( + " [" + + e.index() + + "] " + + PqRegistryHash.fingerprint(reg, e.index()) + + " pkLen=" + + e.publicKey().length + + (a == null ? "" : " addr=0x" + hex(a))); + } + System.out.println(); + System.out.println(" Paste into genesis under \"config\":"); + System.out.println(); + // AERE D-B: the recipe this tool prints has to be the recipe the node accepts. Since 2026-08-06 + // a proof-bound registry scheduled at a block other than its bindHeight is refused at startup, + // so printing one here would be manufacturing the configuration the node rejects - in a file + // that goes to all seven nodes at once. + final long at = block == 0L && reg.proofBound() ? reg.bindHeight() : block; + if (reg.proofBound() && at != reg.bindHeight()) { + System.err.println(); + System.err.println( + "REFUSING TO PRINT A GENESIS FRAGMENT. --block " + + block + + " does not equal this registry's bindHeight " + + reg.bindHeight() + + "."); + System.err.println( + " Every row of this file carries a Falcon possession proof and an ECDSA claim, and both"); + System.err.println( + " sign the bind height. Scheduling it from another block is an activation day no"); + System.err.println( + " validator signed, and a node handed it refuses to start (AERE-PQC-REG-BIND-08)."); + System.err.println( + " Either pass --block " + reg.bindHeight() + ", or re-run the key ceremony for block"); + System.err.println( + " " + block + " - which produces a DIFFERENT hash, because the height is inside it."); + System.exit(4); + return; + } + if (at == 0L) { + System.out.println(" \"pqRegistryHash\": \"0x" + canonical + "\""); + System.out.println(); + System.out.println(" (that binds from block 0. On a live chain use --block instead, so"); + System.out.println(" the existing history stays completely unbound.)"); + } else { + System.out.println(" \"pqRegistryHash\": ["); + System.out.println(" { \"block\": " + at + ", \"hash\": \"0x" + canonical + "\" }"); + System.out.println(" ]"); + System.out.println(); + System.out.println(" Every height below " + at + " stays unbound, so nothing already on"); + System.out.println(" the chain is affected. A later rotation is a further entry with a"); + System.out.println(" STRICTLY greater block."); + } + } + + private static String require(final String[] args, final int i, final String what) { + if (i >= args.length) { + System.err.println(what + " needs a value"); + System.exit(2); + } + return args[i]; + } + + private static String hex(final byte[] b) { + final StringBuilder s = new StringBuilder(b.length * 2); + for (final byte x : b) { + s.append(Character.forDigit((x >> 4) & 0xf, 16)).append(Character.forDigit(x & 0xf, 16)); + } + return s.toString(); + } + + private static void usage(final java.io.PrintStream o) { + o.println( + "usage: PqRegistryHashTool [--chain-id N] [--block H] [--quiet]\n" + + " a legacy .properties registry, a JSON manifest, or a genesis\n" + + " file carrying config.aereFalconRegistry. Format is sniffed.\n" + + " --chain-id N chain id bound into the hashed pre-image (default 2800)\n" + + " --block H emit the genesis snippet as a height-bound schedule at H\n" + + " --quiet print only 0x, for scripts\n" + + "exit: 0 ok, 2 usage, 3 the registry file was rejected"); + } +} diff --git a/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/AereAnchorProposalDelayTest.java b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/AereAnchorProposalDelayTest.java new file mode 100755 index 000000000..39753f013 --- /dev/null +++ b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/AereAnchorProposalDelayTest.java @@ -0,0 +1,106 @@ +/* + * Copyright contributors to Aere Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in + * compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is + * distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the License for the specific language governing permissions and limitations under the + * License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Map; +import java.util.OptionalInt; +import org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * D-337 (2026-09-04): the extra wait before a proposal at an ANCHOR height, and only there. + * + *

The measured defect: with the hybrid certificate live on chain 2800 the proposal timer fired + * before the sixth SLH-DSA seal of the parent arrived, the producer refused, and eleven of twelve + * anchors paid the four-second round-change timeout. The fix is local timing, so the pinned + * properties are exactly two: the value is refused when it is nonsense (a bad delay must stop the + * node at start, not surprise the state machine), and it is added at anchor heights ONLY, so an + * ordinary block is never slowed by a millisecond. + */ +class AereAnchorProposalDelayTest { + + @BeforeEach + @AfterEach + void clean() { + System.clearProperty(AereAnchorProposalDelay.PROPERTY); + AereAnchorProposalDelay.forgetForTesting(); + PqAnchorProducer.useConfigForTesting(null); + } + + @Test + void unsetMeansExactlyTodaysBehaviour() { + assertThat(AereAnchorProposalDelay.configuredMillis()).isZero(); + assertThat(AereAnchorProposalDelay.millisFor(13_014_016L)).isZero(); + } + + @Test + void nonsenseIsRefused() { + assertThatThrownBy(() -> AereAnchorProposalDelay.parse("later")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("AERE-PQC-ANCHOR-CONF-05"); + assertThatThrownBy(() -> AereAnchorProposalDelay.parse("-1")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("outside"); + assertThatThrownBy(() -> AereAnchorProposalDelay.parse("5001")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("outside"); + assertThat(AereAnchorProposalDelay.parse("1200")).isEqualTo(1200L); + assertThat(AereAnchorProposalDelay.parse(null)).isZero(); + assertThat(AereAnchorProposalDelay.parse(" ")).isZero(); + } + + @Test + void theWaitAppliesAtAnchorHeightsAndNowhereElse() { + // the live shape of chain 2800: anchors from 13,014,000, every 32nd block + final PqAnchorConfig cfg = + new PqAnchorConfig( + 2800L, + 13_014_000L, + Map.of(13_014_000L, 6), + OptionalInt.empty(), + false, + OptionalInt.empty(), + OptionalInt.of(32), + 13_014_000L); + PqAnchorProducer.useConfigForTesting(cfg); + System.setProperty(AereAnchorProposalDelay.PROPERTY, "1200"); + AereAnchorProposalDelay.forgetForTesting(); + + assertThat(AereAnchorProposalDelay.configuredMillis()).isEqualTo(1200L); + assertThat(cfg.anchorAppliesAt(13_014_032L)).isTrue(); + assertThat(AereAnchorProposalDelay.millisFor(13_014_032L)).isEqualTo(1200L); + + // the parent of an anchor, and an ordinary block: not a millisecond + assertThat(AereAnchorProposalDelay.millisFor(13_014_031L)).isZero(); + assertThat(AereAnchorProposalDelay.millisFor(13_014_033L)).isZero(); + // below the activation height nothing is an anchor + assertThat(AereAnchorProposalDelay.millisFor(13_013_968L)).isZero(); + } + + @Test + void aChainWithoutAnAnchorNeverWaits() { + PqAnchorProducer.useConfigForTesting(PqAnchorConfig.never(2800L)); + System.setProperty(AereAnchorProposalDelay.PROPERTY, "1200"); + AereAnchorProposalDelay.forgetForTesting(); + assertThat(AereAnchorProposalDelay.millisFor(13_014_032L)).isZero(); + assertThat(AereAnchorProposalDelay.millisFor(1L)).isZero(); + } +} diff --git a/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D177NeutralNamesTest.java b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D177NeutralNamesTest.java new file mode 100755 index 000000000..05136c6a1 --- /dev/null +++ b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D177NeutralNamesTest.java @@ -0,0 +1,97 @@ +/* + * Copyright contributors to the AERE Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file + * except in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +/** + * D-177: the operator-facing switches must not name an algorithm. Every legacy {@code + * aere.falcon.*} property has an algorithm-neutral twin {@code aere.pq.sig.*}, read FIRST, with + * the legacy spelling kept as a working fallback, and a loud refusal when the two spellings + * disagree - the 2026-08-09 lost-fork incident is exactly what a silent preference would invite. + * + *

These tests exercise the single choke point every configured value passes through + * ({@link FalconSealSupport#resolve}), so the four behaviours are proven once for all sixteen + * switches instead of sixteen times over. + */ +class D177NeutralNamesTest { + + private static final String LEGACY = "aere.falcon.validatorCount"; + private static final String NEUTRAL = "aere.pq.sig.validatorCount"; + private static final String ENV = "AERE_FALCON_VALIDATOR_COUNT"; + + @AfterEach + void clear() { + System.clearProperty(LEGACY); + System.clearProperty(NEUTRAL); + } + + @Test + void neutralNameAloneIsRead() { + System.setProperty(NEUTRAL, "9"); + assertThat(FalconSealSupport.resolve(LEGACY, ENV)).isEqualTo("9"); + } + + @Test + void legacyNameAloneStillWorks() { + System.setProperty(LEGACY, "7"); + assertThat(FalconSealSupport.resolve(LEGACY, ENV)).isEqualTo("7"); + } + + @Test + void bothNamesSameValueIsAMigrationWindow() { + System.setProperty(NEUTRAL, "9"); + System.setProperty(LEGACY, "9"); + assertThat(FalconSealSupport.resolve(LEGACY, ENV)).isEqualTo("9"); + } + + @Test + void bothNamesDifferentValuesRefuseLoudly() { + System.setProperty(NEUTRAL, "9"); + System.setProperty(LEGACY, "7"); + assertThatThrownBy(() -> FalconSealSupport.resolve(LEGACY, ENV)) + .isInstanceOf(FalconSealSupport.ActivationConfigException.class) + .hasMessageContaining("AERE-PQC-CFG-DUAL-NAME-01") + .hasMessageContaining(NEUTRAL) + .hasMessageContaining(LEGACY); + } + + @Test + void neutralNameWinsWhenBothAreSemanticallyEqual() { + // same text with different whitespace: trim makes them equal, and the neutral value is the + // one returned, so new fleets can write only the neutral name with no surprise + System.setProperty(NEUTRAL, " 9 "); + System.setProperty(LEGACY, "9"); + assertThat(FalconSealSupport.resolve(LEGACY, ENV)).isEqualTo(" 9 "); + } + + @Test + void nonFalconPropertiesAreLeftUntouched() { + // a property that does not start with aere.falcon. gets no twin: resolve stays exactly the + // reader it was before for it + System.setProperty("aere.pq.anchorBlock", "13014000"); + try { + assertThat(FalconSealSupport.resolve("aere.pq.anchorBlock", "AERE_PQ_ANCHOR_BLOCK")) + .isEqualTo("13014000"); + } finally { + System.clearProperty("aere.pq.anchorBlock"); + } + } +} diff --git a/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/FalconAttachIntervalTest.java b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/FalconAttachIntervalTest.java new file mode 100755 index 000000000..014244362 --- /dev/null +++ b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/FalconAttachIntervalTest.java @@ -0,0 +1,228 @@ +/* + * AERE, 2026-08-08. The interval and the cap on the path BEFORE the anchor is activated. + * + * WHY THIS EXISTS, and the cost that demanded it. On 8 August Falcon seal attachment was switched + * on across all seven validators of chain 2800. The header went from 525 to 3844 bytes, that is + * FIVE seals on EVERY block. At ~60.3 million blocks per year that is ~200 GB per year per node, + * measured, and the tightest host had 12 GB free. That is 23 days. + * + * AND NOW THE PART THAT IS THE ACTUAL FINDING. The anchor producer already had both an interval and + * a cap, built and proven on 7 August. But the assembler that runs when the anchor is NOT armed had + * neither, and that is precisely the assembler that runs up to the activation height. The cost + * control existed and was out of reach exactly in the window where it was needed. `anchorInterval` + * on its own does not help: without `aere.pq.anchorBlock` it is completely inert, so there is no + * middle road. + * + * WHY IT IS SAFE, and this is the argument that has to hold, not the disk saving. The preimage of a + * Falcon seal contains `parentHash`, which covers the parent header, which covers the grandparent, + * and so on down to genesis. So ONE seal over block H transitively covers ALL the history below H. + * You do not need a signature on every block in order to defend every block. What you give up with + * interval N is exactly and only this: an adversary holding every ECDSA key and no Falcon key can + * rewrite up to N blocks back from the last anchor, instead of 1. The property is + * + * fork depth <= interval + * + * and it is a dial, not an accident. Algorand ships the same shape at 1 in 256 or rarer. + * + * Same property as in `PqAnchorIntervalTest`, a different code path. Two paths need two proofs: + * precisely because we had a proof on one of them only, we paid 200 GB per year on the other. + */ +package org.hyperledger.besu.consensus.common.bft; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.OptionalInt; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class FalconAttachIntervalTest { + + private static final long ATTACH = 12_900_000L; + private static final String P_INTERVAL = "aere.falcon.attachInterval"; + private static final String P_CAP = "aere.falcon.attachMaxSeals"; + + @AfterEach + void curata() { + System.clearProperty(P_INTERVAL); + System.clearProperty(P_CAP); + } + + // --------------------------------------------------------------------------------------------- + // 1. THE COMPATIBILITY BOUNDARY. Unset means EVERY block, that is exactly today's behaviour. A + // cap or an interval shipped as a default would be a behaviour change on the live chain + // smuggled in through a version bump, and such a change is asked for, not shipped. + // --------------------------------------------------------------------------------------------- + @Test + void withNoIntervalEveryHeightCarriesACertificate() { + for (final long n : new long[] {ATTACH, ATTACH + 1, ATTACH + 2, ATTACH + 99, ATTACH + 100_000}) { + assertThat(FalconSealSupport.isAttachHeight(n, ATTACH, OptionalInt.empty())) + .as("height %d", n) + .isTrue(); + } + } + + // --------------------------------------------------------------------------------------------- + // 2. Below the attach height nothing is written, with or without an interval. + // --------------------------------------------------------------------------------------------- + @Test + void belowTheAttachHeightNothingIsWritten() { + for (final OptionalInt every : + new OptionalInt[] {OptionalInt.empty(), OptionalInt.of(1), OptionalInt.of(100)}) { + assertThat(FalconSealSupport.isAttachHeight(ATTACH - 1, ATTACH, every)).isFalse(); + assertThat(FalconSealSupport.isAttachHeight(0, ATTACH, every)).isFalse(); + } + } + + // --------------------------------------------------------------------------------------------- + // 3. An interval of 1 is the same thing written out explicitly, and must behave like "unset". + // --------------------------------------------------------------------------------------------- + @Test + void anIntervalOfOneIsTheSameAsNoInterval() { + for (final long n : new long[] {ATTACH, ATTACH + 1, ATTACH + 7, ATTACH + 1000}) { + assertThat(FalconSealSupport.isAttachHeight(n, ATTACH, OptionalInt.of(1))) + .as("height %d", n) + .isEqualTo(FalconSealSupport.isAttachHeight(n, ATTACH, OptionalInt.empty())); + } + } + + // --------------------------------------------------------------------------------------------- + // 4. THE NEGATIVE CONTROL OF THE PROOF. At interval 100 exactly one height in a hundred carries a + // certificate. We count, we do not sample: a proof that checked three heights I picked myself + // would pass even if the arithmetic were wrong between them. + // --------------------------------------------------------------------------------------------- + @Test + void anIntervalOfOneHundredCarriesExactlyOneHeightInOneHundred() { + int with = 0; + for (long n = ATTACH; n < ATTACH + 10_000; n++) { + if (FalconSealSupport.isAttachHeight(n, ATTACH, OptionalInt.of(100))) { + with++; + assertThat((n - ATTACH) % 100).as("height %d is not a multiple", n).isZero(); + } + } + assertThat(with).isEqualTo(100); + + // and the same span of heights WITH NO interval, so the difference we are buying is visible + int without = 0; + for (long n = ATTACH; n < ATTACH + 10_000; n++) { + if (FalconSealSupport.isAttachHeight(n, ATTACH, OptionalInt.empty())) { + without++; + } + } + assertThat(without).isEqualTo(10_000); + } + + // --------------------------------------------------------------------------------------------- + // 5. The first attach height always carries a certificate, whatever the interval. If it were + // skipped, the arming evidence a restarted node looks for could be missing at the start. + // --------------------------------------------------------------------------------------------- + @Test + void theAttachHeightItselfAlwaysCarriesACertificate() { + for (final int every : new int[] {1, 2, 32, 100, 128}) { + assertThat(FalconSealSupport.isAttachHeight(ATTACH, ATTACH, OptionalInt.of(every))) + .as("interval %d", every) + .isTrue(); + } + } + + // --------------------------------------------------------------------------------------------- + // 6. THE UPPER LIMIT ON THE INTERVAL IS NOT A MATTER OF TASTE. It is the 128 block window in + // which a restarted validator looks for its evidence that the fleet is already armed. With a + // wider interval that window can contain no sealed header at all, and the node then refuses to + // start days later, on some other restart, on the host whose disk has just failed. The only + // place where the operator can still see that connection is right here, at configuration time. + // --------------------------------------------------------------------------------------------- + @Test + void anIntervalWiderThanTheRestartEvidenceWindowIsRefused() { + System.setProperty(P_INTERVAL, "129"); + assertThatThrownBy(() -> FalconSealSupport.instance().attachInterval()) + .isInstanceOf(FalconSealSupport.ActivationConfigException.class) + .hasMessageContaining("AERE-PQC-CFG-UNSAFE-09") + .hasMessageContaining("128"); + + // positive control on the SAME path: exactly 128 has to pass, otherwise the message above could + // be coming from anything else and the proof would not be measuring the boundary + System.setProperty(P_INTERVAL, "128"); + assertThat(FalconSealSupport.instance().attachInterval()).hasValue(128); + } + + @Test + void anIntervalBelowOneIsRefused() { + System.setProperty(P_INTERVAL, "0"); + assertThatThrownBy(() -> FalconSealSupport.instance().attachInterval()) + .isInstanceOf(FalconSealSupport.ActivationConfigException.class) + .hasMessageContaining("AERE-PQC-CFG-UNSAFE-09"); + } + + // --------------------------------------------------------------------------------------------- + // 7. A malformed interval STOPS startup. It does not fall back silently to "every block": that + // would leave the operator sure a disk control is in force while the disk fills at the old + // rate. It is exactly the shape of failure that has cost us the most, a green earned by not + // asking. + // --------------------------------------------------------------------------------------------- + @Test + void aMalformedIntervalRefusesToStartRatherThanSilentlyReverting() { + System.setProperty(P_INTERVAL, "one hundred"); + assertThatThrownBy(() -> FalconSealSupport.instance().attachInterval()) + .isInstanceOf(FalconSealSupport.ActivationConfigException.class) + .hasMessageContaining("AERE-PQC-CFG-SYNTAX-09"); + } + + @Test + void anUnsetIntervalMeansEveryBlock() { + assertThat(System.getProperty(P_INTERVAL)).isNull(); + assertThat(FalconSealSupport.instance().attachInterval()).isEmpty(); + } + + // --------------------------------------------------------------------------------------------- + // 8. THE SEAL CAP. Unset means no cap. Zero is refused, because a certificate of zero seals is + // not a cheaper one, it is an absent one, and the road for that is to not attach at all. + // --------------------------------------------------------------------------------------------- + @Test + void theSealCapDefaultsToNoCapAndRefusesZero() { + assertThat(FalconSealSupport.instance().attachMaxSeals()).isEmpty(); + + System.setProperty(P_CAP, "3"); + assertThat(FalconSealSupport.instance().attachMaxSeals()).hasValue(3); + + System.setProperty(P_CAP, "0"); + assertThatThrownBy(() -> FalconSealSupport.instance().attachMaxSeals()) + .isInstanceOf(FalconSealSupport.ActivationConfigException.class) + .hasMessageContaining("AERE-PQC-CFG-UNSAFE-10"); + + System.setProperty(P_CAP, "three"); + assertThatThrownBy(() -> FalconSealSupport.instance().attachMaxSeals()) + .isInstanceOf(FalconSealSupport.ActivationConfigException.class) + .hasMessageContaining("AERE-PQC-CFG-SYNTAX-10"); + } + + // --------------------------------------------------------------------------------------------- + // 9. THE COST, computed from the very arithmetic above and from the measurement on the live + // chain. This proof exists so that it breaks if someone changes the arithmetic and believes + // they only changed a detail. The figures: 525 bytes for the base header, ~662 per seal + // measured in extraData, 60,300,000 blocks per year. + // --------------------------------------------------------------------------------------------- + @Test + void theMeasuredCostOfEachSettingIsWhatWeToldTheFounder() { + final long blocuriPeAn = 60_300_000L; + final long bytesPerSeal = 662L; + + assertThat(gbPeAn(5, 1, bytesPerSeal, blocuriPeAn)).isBetween(180L, 210L); // today + assertThat(gbPeAn(3, 1, bytesPerSeal, blocuriPeAn)).isBetween(105L, 125L); // cap only + assertThat(gbPeAn(3, 32, bytesPerSeal, blocuriPeAn)).isBetween(3L, 5L); // cap + 32 + assertThat(gbPeAn(3, 100, bytesPerSeal, blocuriPeAn)).isBetween(1L, 2L); // cap + 100 + + // and the boundary that matters for the disk decision: at 12 GB free, how many days are left + assertThat(zile(12L, gbPeAn(5, 1, bytesPerSeal, blocuriPeAn))).isLessThan(30L); + assertThat(zile(12L, gbPeAn(3, 32, bytesPerSeal, blocuriPeAn))).isGreaterThan(700L); + } + + private static long gbPeAn( + final int seals, final int interval, final long bytesPerSeal, final long blocuriPeAn) { + return (long) seals * bytesPerSeal * blocuriPeAn / interval / (1024L * 1024L * 1024L); + } + + private static long zile(final long gbLiberi, final long gbPeAn) { + return gbPeAn == 0 ? Long.MAX_VALUE : gbLiberi * 365L / gbPeAn; + } +} diff --git a/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/HybridSealProducerTest.java b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/HybridSealProducerTest.java new file mode 100755 index 000000000..5569abeb7 --- /dev/null +++ b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/HybridSealProducerTest.java @@ -0,0 +1,177 @@ +/* AERE HYBRID: the PRODUCER's proofs, and its pairing with enforcement. + * + * The proof that ties the two halves is the last one: what the producer PRODUCES must pass + * exactly the verification the consumer performs, with real test keys and the same message. + * Two halves proven separately that were never put end to end are the very pattern that cost + * us the most (D-150: every shape-level check had passed). */ +package org.hyperledger.besu.consensus.common.bft; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.hyperledger.besu.crypto.SecureRandomProvider; + +import java.security.SecureRandom; +import java.util.List; +import java.util.Map; + +import org.apache.tuweni.bytes.Bytes; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class HybridSealProducerTest { + + private static final long H_ATASARE = 500L; + private static final long H_HIBRID = 1_000L; + private static final int INDEX = 4; + private static final Bytes MESSAGE = Bytes.fromHexString("0x" + "5a".repeat(32)); + + private SealScheme.GeneratedPair slh; + private PqSchemeSchedule orar; + + private final SecureRandom random = SecureRandomProvider.createSecureRandom(); + + @BeforeEach + void setup() { + slh = SealSchemes.SLH_DSA_128S.generate(random); + orar = + PqSchemeSchedule.parse( + "0:" + SealSchemes.FALCON_512.id() + + "," + H_HIBRID + ":" + SealSchemes.FALCON_512.id() + + "+" + SealSchemes.SLH_DSA_128S.id()); + } + + private HybridSealProducer producator() { + return new HybridSealProducer( + H_ATASARE, orar, INDEX, Map.of(SealSchemes.SLH_DSA_128S.id(), slh.privateKey())); + } + + // ---------------------------------------------------------------- poarta de emisie + + @Test + void theDefaultProducerNeverEmitsAnything() { + assertThat(HybridSealProducer.disarmed().sealsFor(Long.MAX_VALUE - 1, MESSAGE)).isEmpty(); + assertThat(HybridSealProducer.disarmed().attachmentArmedAt(Long.MAX_VALUE - 1)).isFalse(); + } + + @Test + void belowTheAttachmentHeightNothingIsEmitted() { + assertThat(producator().sealsFor(H_ATASARE - 1, MESSAGE)).isEmpty(); + } + + @Test + void betweenAttachmentAndTheHybridStepThereIsNothingToAdd() { + // the gate is open, but the schedule requires only Falcon, which has its own slot: zero extras, correctly + assertThat(producator().attachmentArmedAt(H_ATASARE)).isTrue(); + assertThat(producator().sealsFor(H_ATASARE, MESSAGE)).isEmpty(); + } + + /** + * THE PROOF THAT ACTUALLY SEPARATES THE TWO CASES. The first form of the boundary proof + * above passed for the wrong reason: below the attach height the schedule required no extra + * scheme anyway, so an empty list said nothing about the gate. Here the schedule REQUIRES, + * and the only remaining difference is the gate. Without this, a producer with its gate + * removed would have stayed green. + */ + @Test + void theGateAloneSuppressesEmissionEvenWhenTheScheduleDemandsIt() { + final HybridSealProducer poartaInchisa = + new HybridSealProducer( + H_HIBRID + 100, + orar, + INDEX, + Map.of(SealSchemes.SLH_DSA_128S.id(), slh.privateKey())); + assertThat(poartaInchisa.sealsFor(H_HIBRID, MESSAGE)).isEmpty(); + assertThat(poartaInchisa.sealsFor(H_HIBRID + 100, MESSAGE)).hasSize(1); + } + + @Test + void atTheHybridStepTheExtraSealIsProduced() { + final List seals = producator().sealsFor(H_HIBRID, MESSAGE); + assertThat(seals).hasSize(1); + assertThat(seals.get(0).getSchemeWireId()).isEqualTo(SealSchemes.SLH_DSA_128S.wireId()); + assertThat(seals.get(0).getValidatorIndex()).isEqualTo(INDEX); + } + + // ---------------------------------------------------------------- jumatatea de certificat + + @Test + void aMissingKeyEmitsNothingAtAllRatherThanAStubCertificate() { + final HybridSealProducer withoutKey = + new HybridSealProducer(H_ATASARE, orar, INDEX, Map.of()); + assertThat(withoutKey.sealsFor(H_HIBRID, MESSAGE)).isEmpty(); + } + + @Test + void aNullMessageIsRefusedWithoutThrowing() { + assertThat(producator().sealsFor(H_HIBRID, null)).isEmpty(); + } + + // ---------------------------------------------------------------- dus-intorsul cheii + + @Test + void theSlhDsaPrivateKeySurvivesSerializationAndStillSigns() { + final byte[] encoded = + SealSchemes.SLH_DSA_128S.serializePrivateKey(slh.privateKey()).orElseThrow(); + final SealScheme.PrivateHandle back = + SealSchemes.SLH_DSA_128S.parsePrivateKey(encoded).orElseThrow(); + + final byte[] semnat = + SealSchemes.SLH_DSA_128S.sign(back, MESSAGE.toArray()).orElseThrow(); + assertThat( + SealSchemes.SLH_DSA_128S.verifyRaw( + slh.publicRegistryForm(), MESSAGE.toArray(), semnat)) + .isTrue(); + } + + @Test + void garbageIsNotAPrivateKeyAndFalconDeliberatelyHasNoEncoding() { + assertThat(SealSchemes.SLH_DSA_128S.parsePrivateKey(new byte[] {1, 2, 3})).isEmpty(); + assertThat(SealSchemes.SLH_DSA_128S.parsePrivateKey(null)).isEmpty(); + // Falcon NU implementeaza dus-intorsul: incarcarea lui de productie ramane pe componente, + // neatinsa. Daca cineva o implementeaza intr-o zi, proba asta il obliga sa se uite aici. + final SealScheme.GeneratedPair falcon = SealSchemes.FALCON_512.generate(random); + assertThat(SealSchemes.FALCON_512.serializePrivateKey(falcon.privateKey())).isEmpty(); + } + + // ---------------------------------------------------------------- CELE DOUA JUMATATI, LEGATE + + @Test + void whatTheProducerEmitsIsExactlyWhatTheRegistryVerifies() { + // producatorul semneaza... + final List produse = producator().sealsFor(H_HIBRID, MESSAGE); + assertThat(produse).hasSize(1); + + // ...and a REAL hybrid registry, built from properties as in production, verifies it + // the registry REFUSES a missing entry (its guard, first caught by this very proof), + // so it is built whole: every validator up to our index + final java.util.Properties p = new java.util.Properties(); + p.setProperty("formatVersion", HybridSignerRegistry.FORMAT_VERSION); + p.setProperty("chainId", "2800"); + p.setProperty("count", String.valueOf(INDEX + 1)); + for (int i = 0; i <= INDEX; i++) { + p.setProperty(i + ".addr", "0x" + String.format("%02x", 0xc0 + i).repeat(20)); + final byte[] pub = + i == INDEX + ? slh.publicRegistryForm() + : SealSchemes.SLH_DSA_128S.generate(random).publicRegistryForm(); + p.setProperty( + i + ".key." + SealSchemes.SLH_DSA_128S.id(), Bytes.wrap(pub).toHexString()); + } + final HybridSignerRegistry registry = HybridSignerRegistry.fromProperties(p, "proba"); + + final byte[] cheiePublica = + registry.publicKey(INDEX, SealSchemes.SLH_DSA_128S.id()).orElseThrow(); + assertThat( + SealSchemes.SLH_DSA_128S.verifyRaw( + cheiePublica, MESSAGE.toArray(), produse.get(0).getSignature().toArray())) + .isTrue(); + + // the binding's NEGATIVE CONTROL: the same seal over a DIFFERENT message does not pass + assertThat( + SealSchemes.SLH_DSA_128S.verifyRaw( + cheiePublica, + Bytes.fromHexString("0x" + "5b".repeat(32)).toArray(), + produse.get(0).getSignature().toArray())) + .isFalse(); + } +} diff --git a/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/HybridSealSupportTest.java b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/HybridSealSupportTest.java new file mode 100755 index 000000000..971389b4c --- /dev/null +++ b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/HybridSealSupportTest.java @@ -0,0 +1,206 @@ +/* AERE HYBRID: the PRODUCTION loader's proofs. Each configuration half refuses with its own + * code; the happy path reaches a producer that really signs, with REAL test keys, and its + * signature is verified against the public key from the registry loaded off "disk" + * (a fake ConfigReader: no real file, no global property, zero JVM poisoning). */ +package org.hyperledger.besu.consensus.common.bft; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.hyperledger.besu.crypto.SecureRandomProvider; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.apache.tuweni.bytes.Bytes; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class HybridSealSupportTest { + + private static final long H_HIBRID = 900L; + private static final int INDEX = 2; + + private final SecureRandom random = SecureRandomProvider.createSecureRandom(); + private SealScheme.GeneratedPair slh; + private String orar; + private String registruText; + + /** Cititor fals: proprietati si "fisiere" din memorie. */ + private static final class Cititor implements HybridSealSupport.ConfigReader { + final Map props = new HashMap<>(); + final Map files = new HashMap<>(); + + @Override + public String property(final String name) { + return props.get(name); + } + + @Override + public String environment(final String name) { + return null; + } + + @Override + public byte[] file(final String path) throws IOException { + final byte[] b = files.get(path); + if (b == null) { + throw new IOException("nu exista: " + path); + } + return b; + } + } + + @BeforeEach + void setup() { + slh = SealSchemes.SLH_DSA_128S.generate(random); + orar = + "0:" + SealSchemes.FALCON_512.id() + + "," + H_HIBRID + ":" + SealSchemes.FALCON_512.id() + + "+" + SealSchemes.SLH_DSA_128S.id(); + final StringBuilder r = new StringBuilder(); + r.append("formatVersion=").append(HybridSignerRegistry.FORMAT_VERSION).append('\n'); + r.append("chainId=2800\n"); + r.append("count=").append(INDEX + 1).append('\n'); + for (int i = 0; i <= INDEX; i++) { + final byte[] pub = + i == INDEX + ? slh.publicRegistryForm() + : SealSchemes.SLH_DSA_128S.generate(random).publicRegistryForm(); + r.append(i).append(".addr=0x").append(String.format("%02x", 0xd0 + i).repeat(20)).append('\n'); + r.append(i).append(".key.").append(SealSchemes.SLH_DSA_128S.id()).append('=') + .append(Bytes.wrap(pub).toHexString()).append('\n'); + } + registruText = r.toString(); + } + + private Cititor cuPereche() { + final Cititor c = new Cititor(); + c.props.put(HybridSealSupport.PROPERTY_SCHEDULE, orar); + c.props.put(HybridSealSupport.PROPERTY_REGISTRY, "/fals/registru.properties"); + c.files.put("/fals/registru.properties", registruText.getBytes(StandardCharsets.UTF_8)); + return c; + } + + private void withKey(final Cititor c, final int index, final byte[] sk) { + c.props.put( + HybridSealSupport.PROPERTY_KEY_PREFIX + SealSchemes.SLH_DSA_128S.id(), + "/fals/cheia.properties"); + c.files.put( + "/fals/cheia.properties", + ("index=" + index + "\nsk=" + Bytes.wrap(sk).toHexString() + "\n") + .getBytes(StandardCharsets.UTF_8)); + } + + private byte[] skBytes() { + return SealSchemes.SLH_DSA_128S.serializePrivateKey(slh.privateKey()).orElseThrow(); + } + + // ------------------------------------------------------------------ dezarmat si refuzuri + + @Test + void nothingConfiguredMeansTodayByteForByte() { + final HybridSealSupport s = HybridSealSupport.load(new Cititor()); + assertThat(s.schedule()).isEmpty(); + assertThat(s.registry()).isEmpty(); + assertThat(s.producer().sealsFor(Long.MAX_VALUE - 1, Bytes.of(1))).isEmpty(); + } + + @Test + void scheduleWithoutRegistryRefusesAsConf03() { + final Cititor c = new Cititor(); + c.props.put(HybridSealSupport.PROPERTY_SCHEDULE, orar); + assertThatThrownBy(() -> HybridSealSupport.load(c)) + .hasMessageContaining("AERE-PQC-COMMIT-CONF-03"); + } + + @Test + void attachWithoutThePairRefusesAsConf04() { + final Cititor c = new Cititor(); + c.props.put(HybridSealSupport.PROPERTY_ATTACH_BLOCK, "100"); + assertThatThrownBy(() -> HybridSealSupport.load(c)) + .hasMessageContaining("AERE-PQC-HYBRID-CONF-04"); + } + + @Test + void garbageScheduleRefusesLoudly() { + final Cititor c = cuPereche(); + c.props.put(HybridSealSupport.PROPERTY_SCHEDULE, "aiurea:schema-inexistenta"); + assertThatThrownBy(() -> HybridSealSupport.load(c)) + .hasMessageContaining("AERE-PQC-HYBRID-CONF-04"); + } + + @Test + void unreadableRegistryRefusesLoudly() { + final Cititor c = cuPereche(); + c.files.clear(); + assertThatThrownBy(() -> HybridSealSupport.load(c)) + .hasMessageContaining("AERE-PQC-HYBRID-CONF-04"); + } + + @Test + void armedEmissionWithoutALocalKeyRefusesAsConf04() { + final Cititor c = cuPereche(); + c.props.put(HybridSealSupport.PROPERTY_ATTACH_BLOCK, "100"); + assertThatThrownBy(() -> HybridSealSupport.load(c)) + .hasMessageContaining("AERE-PQC-HYBRID-CONF-04") + .hasMessageContaining("cannot produce"); + } + + @Test + void aKeyTheRegistryDoesNotVouchForRefusesAsConf05() { + final Cititor c = cuPereche(); + // my real key, but declared at index 0, where the registry holds a DIFFERENT public key + withKey(c, 0, skBytes()); + assertThatThrownBy(() -> HybridSealSupport.load(c)) + .hasMessageContaining("AERE-PQC-HYBRID-CONF-05") + .hasMessageContaining("does NOT verify"); + } + + @Test + void garbageKeyBytesRefuseAsConf05() { + final Cititor c = cuPereche(); + withKey(c, INDEX, new byte[] {1, 2, 3}); + assertThatThrownBy(() -> HybridSealSupport.load(c)) + .hasMessageContaining("AERE-PQC-HYBRID-CONF-05"); + } + + // ------------------------------------------------------------------ drumul fericit, cap la cap + + @Test + void theLoadedProducerSignsAndTheLoadedRegistryVerifiesIt() { + final Cititor c = cuPereche(); + c.props.put(HybridSealSupport.PROPERTY_ATTACH_BLOCK, "0"); + withKey(c, INDEX, skBytes()); + + final HybridSealSupport s = HybridSealSupport.load(c); + assertThat(s.schedule()).isPresent(); + assertThat(s.registry()).isPresent(); + + final Bytes message = Bytes.fromHexString("0x" + "77".repeat(32)); + final List seals = s.producer().sealsFor(H_HIBRID, message); + assertThat(seals).hasSize(1); + assertThat(seals.get(0).getValidatorIndex()).isEqualTo(INDEX); + + final byte[] pub = + s.registry().get().publicKey(INDEX, SealSchemes.SLH_DSA_128S.id()).orElseThrow(); + assertThat( + SealSchemes.SLH_DSA_128S.verifyRaw( + pub, message.toArray(), seals.get(0).getSignature().toArray())) + .isTrue(); + } + + @Test + void withThePairButNoKeyTheNodeVerifiesButNeverEmits() { + // exactly the state of a validator that received the binary and the registry but not the + // key: its enforcement can work, its emission promises nothing + final HybridSealSupport s = HybridSealSupport.load(cuPereche()); + assertThat(s.schedule()).isPresent(); + assertThat(s.registry()).isPresent(); + assertThat(s.producer().sealsFor(H_HIBRID, Bytes.of(1))).isEmpty(); + } +} diff --git a/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/HybridSignerRegistryTest.java b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/HybridSignerRegistryTest.java new file mode 100755 index 000000000..87bca049e --- /dev/null +++ b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/HybridSignerRegistryTest.java @@ -0,0 +1,225 @@ +/* AERE crypto-agility, step 3 proofs. The registry's job is to REFUSE: every acceptance test here + * is outnumbered by refusal tests, because blocante_armare (2026-08-06) measured what a lenient + * loader costs: a mistyped comma boots the node DISARMED and nothing shouts. Keys are throwaway + * pairs generated per run; no real validator key exists anywhere near this file. */ +package org.hyperledger.besu.consensus.common.bft; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.security.SecureRandom; +import java.util.Properties; + +import org.apache.tuweni.bytes.Bytes; +import org.hyperledger.besu.crypto.SecureRandomProvider; +import org.junit.jupiter.api.Test; + +class HybridSignerRegistryTest { + + private static final String FALCON = "falcon-512"; + private static final String SLHDSA = "slh-dsa-128s"; + + private final SecureRandom random = SecureRandomProvider.createSecureRandom(); + + /** 3 validators: 0 hybrid (both schemes), 1 falcon-only, 2 hybrid. */ + private Properties sanatos() { + final Properties p = new Properties(); + p.setProperty("formatVersion", "hybrid-1"); + p.setProperty("chainId", "2800"); + p.setProperty("count", "3"); + for (int i = 0; i < 3; i++) { + p.setProperty(i + ".addr", "0x" + String.format("%040x", 0xA0 + i)); + p.setProperty( + i + ".key." + FALCON, + Bytes.wrap(SealSchemes.FALCON_512.generate(random).publicRegistryForm()).toHexString()); + } + for (final int i : new int[] {0, 2}) { + p.setProperty( + i + ".key." + SLHDSA, + Bytes.wrap(SealSchemes.SLH_DSA_128S.generate(random).publicRegistryForm()).toHexString()); + } + return p; + } + + // ------------------------------------------------------------------ acceptance + + @Test + void healthyHybridRegistryLoadsWithRightCoverage() { + final HybridSignerRegistry reg = HybridSignerRegistry.fromProperties(sanatos(), "test"); + assertThat(reg.size()).isEqualTo(3); + assertThat(reg.chainId()).isEqualTo(2800); + assertThat(reg.coverage(FALCON)).isEqualTo(3); + assertThat(reg.coverage(SLHDSA)).isEqualTo(2); + assertThat(reg.publicKey(0, FALCON)).isPresent(); + assertThat(reg.publicKey(0, SLHDSA)).isPresent(); + assertThat(reg.publicKey(1, SLHDSA)).isEmpty(); // falcon-only validator + assertThat(reg.publicKey(9, FALCON)).isEmpty(); // absent index + assertThat(reg.schemesOf(0)).containsExactly(FALCON, SLHDSA); // canonical id order + assertThat(reg.address(1)).isPresent(); + // keys parse under their scheme and have the measured lengths (896 / 32) + assertThat(reg.publicKey(0, FALCON).orElseThrow()).hasSize(896); + assertThat(reg.publicKey(0, SLHDSA).orElseThrow()).hasSize(32); + } + + // ------------------------------------------------------------------ refusals + + @Test + void unknownSchemeSuffixRefusesTheWholeRegistryByName() { + final Properties p = sanatos(); + p.setProperty("1.key.dilithium-notyet", "0x1234"); + assertThatThrownBy(() -> HybridSignerRegistry.fromProperties(p, "test")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dilithium-notyet"); + } + + @Test + void wrongKeyLengthForItsSchemeRefuses() { + final Properties p = sanatos(); + p.setProperty("1.key." + SLHDSA, "0x" + "ab".repeat(31)); // 31, not 32 + assertThatThrownBy(() -> HybridSignerRegistry.fromProperties(p, "test")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("exactly"); + } + + @Test + void falconKeyInSlhSlotRefuses() { + // an 896-byte value under the slh-dsa suffix: length check must catch the swap + final Properties p = sanatos(); + p.setProperty( + "1.key." + SLHDSA, + Bytes.wrap(SealSchemes.FALCON_512.generate(random).publicRegistryForm()).toHexString()); + assertThatThrownBy(() -> HybridSignerRegistry.fromProperties(p, "test")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void missingAddressRefuses() { + final Properties p = sanatos(); + p.remove("1.addr"); + assertThatThrownBy(() -> HybridSignerRegistry.fromProperties(p, "test")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("1.addr"); + } + + @Test + void holeInTheIndexSequenceRefuses() { + final Properties p = sanatos(); + p.remove("1.addr"); + p.remove("1.key." + FALCON); + assertThatThrownBy(() -> HybridSignerRegistry.fromProperties(p, "test")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void entriesBeyondCountRefuse() { + final Properties p = sanatos(); + p.setProperty("7.addr", "0x" + "cd".repeat(20)); + p.setProperty( + "7.key." + FALCON, + Bytes.wrap(SealSchemes.FALCON_512.generate(random).publicRegistryForm()).toHexString()); + assertThatThrownBy(() -> HybridSignerRegistry.fromProperties(p, "test")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("beyond count"); + } + + @Test + void wrongFormatVersionRefuses() { + final Properties p = sanatos(); + p.setProperty("formatVersion", "hybrid-9"); + assertThatThrownBy(() -> HybridSignerRegistry.fromProperties(p, "test")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("hybrid-1"); + } + + @Test + void unrecognisedEntryRefuses() { + final Properties p = sanatos(); + p.setProperty("1.cheie", "0x1234"); // aproape corect, dar nu e nici addr nici key. + assertThatThrownBy(() -> HybridSignerRegistry.fromProperties(p, "test")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("unrecognised"); + } + + // ------------------------------------------------------------------ the canonical hash + + @Test + void hashIsDeterministicAndMovesWithEveryBoundThing() { + final Properties p = sanatos(); + final HybridSignerRegistry a = HybridSignerRegistry.fromProperties(p, "a"); + final HybridSignerRegistry b = HybridSignerRegistry.fromProperties(p, "b"); + assertThat(a.canonicalHash()).isEqualTo(b.canonicalHash()); // determinist + + // schimb O cheie: hash-ul se misca + final Properties altKey = sanatos(); + altKey.setProperty( + "2.key." + SLHDSA, + Bytes.wrap(SealSchemes.SLH_DSA_128S.generate(random).publicRegistryForm()).toHexString()); + assertThat(HybridSignerRegistry.fromProperties(altKey, "c").canonicalHash()) + .isNotEqualTo(a.canonicalHash()); + + // scot o schema de la un validator: hash-ul se misca + final Properties altScheme = sanatos(); + altScheme.remove("2.key." + SLHDSA); + assertThat(HybridSignerRegistry.fromProperties(altScheme, "d").canonicalHash()) + .isNotEqualTo(a.canonicalHash()); + + // alt chainId: hash-ul se misca + final Properties altChain = sanatos(); + altChain.setProperty("chainId", "2801"); + assertThat(HybridSignerRegistry.fromProperties(altChain, "e").canonicalHash()) + .isNotEqualTo(a.canonicalHash()); + } + + @Test + void hashDomainCanNeverCollideWithTheFalconOnlyRegistryFamily() { + // the domain is part of the preimage; if someone aligned it with the old family, a hybrid + // registry could pass itself off as the genesis-bound v1 registry. The constant is + // verified here so it cannot drift silently. + assertThat(HybridSignerRegistry.HASH_DOMAIN).isEqualTo("AERE-PQ-HYBRID-REGISTRY-1"); + assertThat(HybridSignerRegistry.HASH_DOMAIN).isNotEqualTo(PqRegistryHash.DOMAIN_V1); + assertThat(HybridSignerRegistry.HASH_DOMAIN).isNotEqualTo(PqRegistryHash.DOMAIN_V2); + } + + // --------------------------------------------- the registry + the v2 certificate, together + + @Test + void endToEndCertificateVerifiesAgainstRegistryKeysPerScheme() { + final byte[] message = "commit hash stand-in, 32 bytes!!".getBytes(java.nio.charset.StandardCharsets.UTF_8); + // build the registry and KEEP the private test handles so I can sign + final Properties p = new Properties(); + p.setProperty("formatVersion", "hybrid-1"); + p.setProperty("chainId", "2800"); + p.setProperty("count", "2"); + final SealScheme.GeneratedPair f0 = SealSchemes.FALCON_512.generate(random); + final SealScheme.GeneratedPair s0 = SealSchemes.SLH_DSA_128S.generate(random); + final SealScheme.GeneratedPair f1 = SealSchemes.FALCON_512.generate(random); + p.setProperty("0.addr", "0x" + "aa".repeat(20)); + p.setProperty("1.addr", "0x" + "bb".repeat(20)); + p.setProperty("0.key." + FALCON, Bytes.wrap(f0.publicRegistryForm()).toHexString()); + p.setProperty("0.key." + SLHDSA, Bytes.wrap(s0.publicRegistryForm()).toHexString()); + p.setProperty("1.key." + FALCON, Bytes.wrap(f1.publicRegistryForm()).toHexString()); + final HybridSignerRegistry reg = HybridSignerRegistry.fromProperties(p, "test"); + + // certificatul hibrid: validatorul 0 cu amandoua schemele, 1 doar Falcon + final java.util.List cert = + java.util.List.of( + new SchemeSeal((byte) 0x01, 0, Bytes.wrap( + SealSchemes.FALCON_512.sign(f0.privateKey(), message).orElseThrow())), + new SchemeSeal((byte) 0x02, 0, Bytes.wrap( + SealSchemes.SLH_DSA_128S.sign(s0.privateKey(), message).orElseThrow())), + new SchemeSeal((byte) 0x01, 1, Bytes.wrap( + SealSchemes.FALCON_512.sign(f1.privateKey(), message).orElseThrow()))); + + // round-trip through the v2 format, then EACH seal against ITS OWN key from the registry + for (final SchemeSeal seal : PqAnchorV2.decode(PqAnchorV2.encode(cert))) { + final SealScheme scheme = SealSchemes.byWireId(seal.getSchemeWireId()).orElseThrow(); + final byte[] key = reg.publicKey(seal.getValidatorIndex(), scheme.id()).orElseThrow(); + assertThat(scheme.verifyRaw(key, message, seal.getSignature().toArray())) + .as("sigiliul %s contra cheii lui din registru", seal) + .isTrue(); + } + // the per-scheme threshold, on the same certificate: 2 Falcon validators, 1 SLH-DSA + assertThat(PqAnchorV2.distinctValidatorsWith(cert, (byte) 0x01)).isEqualTo(2); + assertThat(PqAnchorV2.distinctValidatorsWith(cert, (byte) 0x02)).isEqualTo(1); + } +} diff --git a/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorConfigTest.java b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorConfigTest.java new file mode 100755 index 000000000..6f31c94f9 --- /dev/null +++ b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorConfigTest.java @@ -0,0 +1,754 @@ +/* + * Copyright contributors to Besu / AERE Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.OptionalInt; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** The height gate, the staged threshold, and the two emergency de-arm controls. */ +public class PqAnchorConfigTest { + + /** The activation height used throughout, so H appears as one number in every message. */ + private static final long H = 10_141_734L; + + /** + * A reader over two maps instead of over the JVM and the process environment. + * + *

Two reasons this exists rather than {@code System.setProperty} everywhere. First, hermetic + * tests: an operator or a CI image that happens to export {@code AERE_PQ_ANCHOR_BLOCK} would + * otherwise change what these tests measure, which is precisely the class of accident under + * examination. Second, it records every access, and the count of accesses is what proves that the + * unconfigured path decodes nothing. + */ + private static final class RecordingReader implements PqAnchorConfig.NameReader { + private final Map properties = new HashMap<>(); + private final Map environment = new HashMap<>(); + private final List accesses = new ArrayList<>(); + + @Override + public String property(final String name) { + accesses.add("property:" + name); + return properties.get(name); + } + + @Override + public String environment(final String name) { + accesses.add("environment:" + name); + return environment.get(name); + } + } + + /** The negative control: a reader that reports every name as absent, whatever is really set. */ + private static final class BlindReader implements PqAnchorConfig.NameReader { + @Override + public String property(final String name) { + return null; + } + + @Override + public String environment(final String name) { + return null; + } + } + + private RecordingReader reader; + + @BeforeEach + public void installReader() { + reader = new RecordingReader(); + PqAnchorConfig.useNameReaderForTesting(reader); + } + + @AfterEach + public void restoreReader() { + PqAnchorConfig.useNameReaderForTesting(null); + } + + private void set(final String property, final String value) { + reader.properties.put(property, value); + } + + private void setEnvironment(final String variable, final String value) { + reader.environment.put(variable, value); + } + + /** A schedule that is correct: a step exactly at H, then two staged raises. */ + private void armCorrectly() { + set(PqAnchorConfig.PROPERTY_CHAIN_ID, "2800"); + set(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, Long.toString(H)); + set(PqAnchorConfig.PROPERTY_MIN_SEALS, H + ":0," + (H + 7200) + ":1," + (H + 14400) + ":3"); + } + + private static Map schedule() { + final Map steps = new LinkedHashMap<>(); + steps.put(1000L, 0); + steps.put(2000L, 1); + steps.put(3000L, 3); + steps.put(4000L, 5); + return steps; + } + + private static PqAnchorConfig armed() { + return new PqAnchorConfig(2800L, 1000L, schedule(), OptionalInt.empty(), false); + } + + // =============================================================================================== + // 1. The value object. Unchanged behaviour, kept as the floor under everything below. + // =============================================================================================== + + @Test + public void unconfiguredMeansInertAtEveryHeight() { + final PqAnchorConfig config = PqAnchorConfig.never(2800L); + assertThat(config.everActive()).isFalse(); + assertThat(config.activeAt(0L)).isFalse(); + assertThat(config.activeAt(11_800_000L)).isFalse(); + assertThat(config.activeAt(Long.MAX_VALUE)).isFalse(); + // And the legacy Falcon rule never retires, so the whole rule set behaves exactly as today. + assertThat(config.legacyFalconRuleRetirementBlock()).isEqualTo(Long.MAX_VALUE); + } + + @Test + public void theGateIsOnTheBlockNumberAndNothingBelowHIsTouched() { + final PqAnchorConfig config = armed(); + assertThat(config.activeAt(0L)).isFalse(); + assertThat(config.activeAt(999L)).isFalse(); + assertThat(config.activeAt(1000L)).isTrue(); + assertThat(config.activeAt(1001L)).isTrue(); + } + + @Test + public void thresholdFollowsTheStagedScheduleAndIsZeroAtActivation() { + final PqAnchorConfig config = armed(); + assertThat(config.minSealsAt(1000L)).isEqualTo(0); + assertThat(config.minSealsAt(1999L)).isEqualTo(0); + assertThat(config.minSealsAt(2000L)).isEqualTo(1); + assertThat(config.minSealsAt(2999L)).isEqualTo(1); + assertThat(config.minSealsAt(3000L)).isEqualTo(3); + assertThat(config.minSealsAt(4000L)).isEqualTo(5); + assertThat(config.minSealsAt(9_999_999L)).isEqualTo(5); + } + + @Test + public void emergencyCeilingLowersTheThresholdAndCanNeverRaiseIt() { + // D1, the only de-arm that works when the chain is ALREADY STOPPED: a halted chain cannot + // deliver a height-scheduled configuration change, so the control has to be local to the node. + final PqAnchorConfig lowered = + new PqAnchorConfig(2800L, 1000L, schedule(), OptionalInt.of(1), false); + assertThat(lowered.minSealsAt(4000L)).isEqualTo(1); + assertThat(lowered.minSealsAt(1000L)).isEqualTo(0); + + final PqAnchorConfig raiseAttempt = + new PqAnchorConfig(2800L, 1000L, schedule(), OptionalInt.of(99), false); + assertThat(raiseAttempt.minSealsAt(2000L)).isEqualTo(1); + assertThat(raiseAttempt.minSealsAt(4000L)).isEqualTo(5); + + final PqAnchorConfig floored = + new PqAnchorConfig(2800L, 1000L, schedule(), OptionalInt.of(0), false); + assertThat(floored.minSealsAt(4000L)).isEqualTo(0); + } + + @Test + public void emergencyDisableSwitchesBothRulesOff() { + final PqAnchorConfig disabled = + new PqAnchorConfig(2800L, 1000L, schedule(), OptionalInt.empty(), true); + assertThat(disabled.everActive()).isFalse(); + assertThat(disabled.activeAt(5000L)).isFalse(); + assertThat(disabled.legacyFalconRuleRetirementBlock()).isEqualTo(Long.MAX_VALUE); + } + + @Test + public void invalidConfigurationsAreRefusedAtConstruction() { + // H must leave room for a parent. + assertThatThrownBy( + () -> + new PqAnchorConfig( + 2800L, 0L, Collections.emptyMap(), OptionalInt.empty(), false)) + .isInstanceOf(IllegalArgumentException.class); + // A threshold step cannot take effect before the rules do. + assertThatThrownBy( + () -> + new PqAnchorConfig( + 2800L, 1000L, Map.of(500L, 3), OptionalInt.empty(), false)) + .isInstanceOf(IllegalArgumentException.class); + // A negative threshold is meaningless. + assertThatThrownBy( + () -> + new PqAnchorConfig( + 2800L, 1000L, Map.of(1000L, -1), OptionalInt.empty(), false)) + .isInstanceOf(IllegalArgumentException.class); + } + + // =============================================================================================== + // 2. THE COMPATIBILITY PROPERTY. This is the one the whole design is built around: a binary + // carrying this code, put on a live node that has none of these names set, must behave exactly + // as today. Not approximately. The assertion is on the ACCESS LOG, because "no decode, no + // allocation" is a claim about what the loader touched, not about what it returned. + // =============================================================================================== + + @Test + public void withNoAnchorNameSetAnywhereTheLoaderTouchesNothingAndReturnsTodaysBehaviour() { + final PqAnchorConfig config = PqAnchorConfig.fromSystemConfiguration(); + + assertThat(config.everActive()).isFalse(); + assertThat(config.anchorConfigured()).isFalse(); + assertThat(config.disabled()).isFalse(); + assertThat(config.chainId()).isEqualTo(0L); + assertThat(config.anchorBlock()).isEqualTo(PqAnchorConfig.NEVER); + assertThat(config.minSealsSchedule()).isEmpty(); + assertThat(config.minSealsCeiling()).isEmpty(); + assertThat(config.legacyFalconRuleRetirementBlock()).isEqualTo(PqAnchorConfig.NEVER); + assertThat(config.toString()).isEqualTo(PqAnchorConfig.never(0L).toString()); + for (final long height : new long[] {0L, 1L, H - 1, H, H + 1, Long.MAX_VALUE}) { + assertThat(config.activeAt(height)).isFalse(); + assertThat(config.minSealsAt(height)).isEqualTo(0); + assertThat(config.emergencyCeilingLowersAt(height)).isFalse(); + } + + // THE LOAD-BEARING ASSERTION of this test. SEVEN names, each probed exactly once as a system + // property and once as an environment variable, and then nothing: fourteen accesses, not + // fifteen. A loader that went any further and decoded would read the same names a second time + // and this number would be 28. + // + // IT WAS TEN UNTIL 2026-08-07, and it went up TWICE on the same day, for + // `aere.pq.anchor.maxSeals` and then for `aere.pq.anchorInterval`. Each time the test failed + // and forced somebody to look, which is exactly what it was asked to do. The number is raised + // DELIBERATELY, with the reason written next to it; the assertion is never weakened and never + // deleted. + // + // The consequence of adding a name, and it is intended: a node that has ONLY a cost control + // set, with no anchor height, is no longer "a node with no anchor configuration"; it becomes + // one with an incomplete configuration, and it refuses to start. A cap or an interval with no + // anchor means nothing, and it is better for it to shout than to keep quiet. + // 16 since 2026-09-03: the v2 activation height (property + environment) joined the names + // 18 since 2026-09-04: the interval SCHEDULE (D-336, disk) joined them, between the interval and the v2 height + assertThat(reader.accesses).hasSize(18); + assertThat(reader.accesses) + .containsExactly( + "property:" + PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, + "environment:" + PqAnchorConfig.ENV_ANCHOR_BLOCK, + "property:" + PqAnchorConfig.PROPERTY_MIN_SEALS, + "environment:" + PqAnchorConfig.ENV_MIN_SEALS, + "property:" + PqAnchorConfig.PROPERTY_CHAIN_ID, + "environment:" + PqAnchorConfig.ENV_CHAIN_ID, + "property:" + PqAnchorConfig.PROPERTY_MIN_SEALS_CEILING, + "environment:" + PqAnchorConfig.ENV_MIN_SEALS_CEILING, + // added 2026-08-07 together with the cost cap; the ORDER matters and it is the one in + // ANCHOR_NAMES, where maxSeals sits between the emergency ceiling and the disarm + "property:" + PqAnchorConfig.PROPERTY_MAX_SEALS, + "environment:" + PqAnchorConfig.ENV_MAX_SEALS, + "property:" + PqAnchorConfig.PROPERTY_ANCHOR_INTERVAL, + "environment:" + PqAnchorConfig.ENV_ANCHOR_INTERVAL, + "property:" + PqAnchorConfig.PROPERTY_ANCHOR_INTERVAL_SCHEDULE, + "environment:" + PqAnchorConfig.ENV_ANCHOR_INTERVAL_SCHEDULE, + // added 2026-09-03 with the scheme-tagged (v2) certificate: its activation height sits + // between the interval and the disarm, as in ANCHOR_NAMES + "property:" + PqAnchorConfig.PROPERTY_ANCHOR_V2_BLOCK, + "environment:" + PqAnchorConfig.ENV_ANCHOR_V2_BLOCK, + "property:" + PqAnchorConfig.PROPERTY_DISABLE, + "environment:" + PqAnchorConfig.ENV_DISABLE); + } + + // =============================================================================================== + // 3. THE ARMED PATH. A correct configuration still starts, and the threshold in force is the one + // that was asked for. Without this, "refuses everything" would score as a pass. + // =============================================================================================== + + @Test + public void aCorrectConfigurationStartsAndTheThresholdIsTheOneThatWasAskedFor() { + armCorrectly(); + final PqAnchorConfig config = PqAnchorConfig.fromSystemConfiguration(); + + assertThat(config.everActive()).isTrue(); + assertThat(config.anchorConfigured()).isTrue(); + assertThat(config.chainId()).isEqualTo(2800L); + assertThat(config.anchorBlock()).isEqualTo(H); + assertThat(config.activeAt(H - 1)).isFalse(); + assertThat(config.activeAt(H)).isTrue(); + assertThat(config.minSealsAt(H)).isEqualTo(0); + assertThat(config.minSealsAt(H + 7199)).isEqualTo(0); + assertThat(config.minSealsAt(H + 7200)).isEqualTo(1); + assertThat(config.minSealsAt(H + 14400)).isEqualTo(3); + assertThat(config.minSealsAt(H + 10_000_000)).isEqualTo(3); + assertThat(config.legacyFalconRuleRetirementBlock()).isEqualTo(H); + } + + @Test + public void theEnvironmentFormIsReadTheSameWayAsTheProperty() { + setEnvironment(PqAnchorConfig.ENV_CHAIN_ID, "2800"); + setEnvironment(PqAnchorConfig.ENV_ANCHOR_BLOCK, Long.toString(H)); + setEnvironment(PqAnchorConfig.ENV_MIN_SEALS, H + ":3"); + final PqAnchorConfig config = PqAnchorConfig.fromSystemConfiguration(); + assertThat(config.everActive()).isTrue(); + assertThat(config.minSealsAt(H)).isEqualTo(3); + } + + @Test + public void aCorrectConfigurationReadThroughTheREALSystemPropertiesAlsoStarts() { + // The map reader above is hermetic, which is why it is used everywhere else. This one case goes + // through System.getProperty itself, so that the seam cannot be hiding a difference between the + // reader the tests use and the reader the node uses. + PqAnchorConfig.useNameReaderForTesting(null); + final String[] keys = { + PqAnchorConfig.PROPERTY_CHAIN_ID, + PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, + PqAnchorConfig.PROPERTY_MIN_SEALS, + PqAnchorConfig.PROPERTY_MIN_SEALS_CEILING, + PqAnchorConfig.PROPERTY_DISABLE + }; + final String[] saved = new String[keys.length]; + for (int i = 0; i < keys.length; i++) { + saved[i] = System.getProperty(keys[i]); + } + try { + System.setProperty(PqAnchorConfig.PROPERTY_CHAIN_ID, "2800"); + System.setProperty(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, "12000000"); + System.setProperty( + PqAnchorConfig.PROPERTY_MIN_SEALS, "12000000:0,12007200:1,12014400:3,12021600:5"); + System.clearProperty(PqAnchorConfig.PROPERTY_MIN_SEALS_CEILING); + System.clearProperty(PqAnchorConfig.PROPERTY_DISABLE); + + final PqAnchorConfig config = PqAnchorConfig.fromSystemConfiguration(); + assertThat(config.chainId()).isEqualTo(2800L); + assertThat(config.anchorBlock()).isEqualTo(12_000_000L); + assertThat(config.activeAt(11_999_999L)).isFalse(); + assertThat(config.activeAt(12_000_000L)).isTrue(); + assertThat(config.minSealsAt(12_000_000L)).isEqualTo(0); + assertThat(config.minSealsAt(12_021_600L)).isEqualTo(5); + + System.setProperty(PqAnchorConfig.PROPERTY_MIN_SEALS_CEILING, "1"); + assertThat(PqAnchorConfig.fromSystemConfiguration().minSealsAt(12_021_600L)).isEqualTo(1); + + System.setProperty(PqAnchorConfig.PROPERTY_DISABLE, "true"); + final PqAnchorConfig off = PqAnchorConfig.fromSystemConfiguration(); + assertThat(off.everActive()).isFalse(); + assertThat(off.activeAt(12_021_600L)).isFalse(); + // The anchor is still CONFIGURED, which is what keeps the per-block disarm shout alive. + assertThat(off.anchorConfigured()).isTrue(); + } finally { + for (int i = 0; i < keys.length; i++) { + if (saved[i] == null) { + System.clearProperty(keys[i]); + } else { + System.setProperty(keys[i], saved[i]); + } + } + } + } + + // =============================================================================================== + // 4. THE NINE WAYS THE ANCHOR USED TO GO QUIETLY DISARMED. One case each, every one of them a + // startup refusal now, and every assertion names the code and the field so that a message that + // stops telling the operator which knob to turn goes red here. + // =============================================================================================== + + private void assertRefusesNaming(final String field) { + assertThatThrownBy(PqAnchorConfig::fromSystemConfiguration) + .isInstanceOf(FalconSealSupport.ActivationConfigException.class) + .hasMessageContaining(PqAnchorConfig.REFUSAL_CODE) + .hasMessageContaining("REFUSING TO START") + .hasMessageContaining("FIELD " + field) + .hasMessageContaining("never in parallel"); + } + + /** P1: the emptied shell variable. The quietest path in the whole file until today. */ + @Test + public void p1_anEmptyAnchorBlockIsAMistakeNotAnAbsence() { + set(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, ""); + assertRefusesNaming(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK); + } + + /** P2: an unreadable H used to log a number nobody reads as "the anchor is off". */ + @Test + public void p2_anUnparseableAnchorBlockRefusesInsteadOfRunningInert() { + set(PqAnchorConfig.PROPERTY_CHAIN_ID, "2800"); + set(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, "10_141_734"); + set(PqAnchorConfig.PROPERTY_MIN_SEALS, H + ":3"); + assertRefusesNaming(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK); + } + + /** P3: the discarded schedule. The node armed, and K was 0, and the banner said ARMED. */ + @Test + public void p3_aBrokenStepNoLongerDiscardsTheWholeScheduleAndArmsAtZero() { + set(PqAnchorConfig.PROPERTY_CHAIN_ID, "2800"); + set(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, Long.toString(H)); + set(PqAnchorConfig.PROPERTY_MIN_SEALS, H + ":3," + (H + 7200) + ":"); + assertRefusesNaming(PqAnchorConfig.PROPERTY_MIN_SEALS); + assertThatThrownBy(PqAnchorConfig::fromSystemConfiguration) + .hasMessageContaining("has no threshold after the ':'"); + } + + /** P3, the other shape: one comma too many. */ + @Test + public void p3_aStrayCommaIsARefusalNotASkippedStep() { + set(PqAnchorConfig.PROPERTY_CHAIN_ID, "2800"); + set(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, Long.toString(H)); + set(PqAnchorConfig.PROPERTY_MIN_SEALS, H + ":3,," + (H + 7200) + ":4"); + assertRefusesNaming(PqAnchorConfig.PROPERTY_MIN_SEALS); + } + + /** P3, the emptiest shape: armed with no schedule at all means armed at K=0. */ + @Test + public void p3_anArmedAnchorWithAnEmptyScheduleIsRefused() { + set(PqAnchorConfig.PROPERTY_CHAIN_ID, "2800"); + set(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, Long.toString(H)); + set(PqAnchorConfig.PROPERTY_MIN_SEALS, ""); + assertRefusesNaming(PqAnchorConfig.PROPERTY_MIN_SEALS); + } + + /** + * P4: syntactically perfect, semantically wrong. One digit too many in the first height and K + * stays 0 for ten million blocks, with no log line and no guard anywhere. The rule that kills it + * is a property, not a heuristic: the threshold must be defined AT the activation height. + */ + @Test + public void p4_aScheduleThatDoesNotDefineKAtTheActivationHeightIsRefused() { + set(PqAnchorConfig.PROPERTY_CHAIN_ID, "2800"); + set(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, Long.toString(H)); + set(PqAnchorConfig.PROPERTY_MIN_SEALS, (H * 10) + ":3"); + assertRefusesNaming(PqAnchorConfig.PROPERTY_MIN_SEALS); + assertThatThrownBy(PqAnchorConfig::fromSystemConfiguration) + .hasMessageContaining("no step at the activation height " + H); + } + + /** + * P5: every value the constructor would have rejected used to be caught, logged once and turned + * into {@code never()}, so the node ran with the anchor completely inert and nothing said so + * again. All four shapes refuse now, and each one names the field that is actually wrong rather + * than the first field the loader happened to be holding. + */ + @Test + public void p5_aConfigurationTheConstructorWouldRejectRefusesInsteadOfReturningNever() { + // H with no room for a parent. + set(PqAnchorConfig.PROPERTY_CHAIN_ID, "2800"); + set(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, "0"); + set(PqAnchorConfig.PROPERTY_MIN_SEALS, "0:3"); + assertRefusesNaming(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK); + + // A negative threshold. + installReader(); + armCorrectly(); + set(PqAnchorConfig.PROPERTY_MIN_SEALS, H + ":-1"); + assertRefusesNaming(PqAnchorConfig.PROPERTY_MIN_SEALS); + + // A step that would take effect before the rules do. This is the common one: H is pushed back + // and the schedule is left where it was. + installReader(); + armCorrectly(); + set(PqAnchorConfig.PROPERTY_MIN_SEALS, (H - 1000) + ":0," + H + ":3"); + assertRefusesNaming(PqAnchorConfig.PROPERTY_MIN_SEALS); + } + + /** P6: a negative ceiling disarmed the WHOLE anchor while the banner said "threshold capped". */ + @Test + public void p6_aNegativeCeilingRefusesInsteadOfSilentlyKillingTheAnchor() { + armCorrectly(); + set(PqAnchorConfig.PROPERTY_MIN_SEALS_CEILING, "-1"); + assertRefusesNaming(PqAnchorConfig.PROPERTY_MIN_SEALS_CEILING); + } + + /** P7: an unreadable ceiling was IGNORED, and announced as being in force anyway. */ + @Test + public void p7_anUnparseableCeilingRefusesInsteadOfBeingIgnored() { + armCorrectly(); + set(PqAnchorConfig.PROPERTY_MIN_SEALS_CEILING, "three"); + assertRefusesNaming(PqAnchorConfig.PROPERTY_MIN_SEALS_CEILING); + } + + /** + * P8: the Holesky field, by name. An absent chain id took the default zero, the domain separation + * left the D and M pre-images, and nothing anywhere refused. Holesky ran for months on exactly + * this before it lost finality for two weeks. + */ + @Test + public void p8_anArmedAnchorWithoutAnExplicitChainIdIsRefused() { + set(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, Long.toString(H)); + set(PqAnchorConfig.PROPERTY_MIN_SEALS, H + ":3"); + assertRefusesNaming(PqAnchorConfig.PROPERTY_CHAIN_ID); + } + + /** P8, the explicit zero: written down, still meaningless. */ + @Test + public void p8_anExplicitChainIdOfZeroIsRefused() { + set(PqAnchorConfig.PROPERTY_CHAIN_ID, "0"); + set(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, Long.toString(H)); + set(PqAnchorConfig.PROPERTY_MIN_SEALS, H + ":3"); + assertRefusesNaming(PqAnchorConfig.PROPERTY_CHAIN_ID); + } + + /** A schedule with no activation height can never do anything, and used to say nothing. */ + @Test + public void aScheduleWithoutAnActivationHeightIsRefused() { + set(PqAnchorConfig.PROPERTY_CHAIN_ID, "2800"); + set(PqAnchorConfig.PROPERTY_MIN_SEALS, H + ":3"); + assertRefusesNaming(PqAnchorConfig.PROPERTY_MIN_SEALS); + } + + /** The same height twice with two different thresholds: whichever wins, it is not a decision. */ + @Test + public void aDuplicateHeightWithTwoThresholdsIsRefused() { + set(PqAnchorConfig.PROPERTY_CHAIN_ID, "2800"); + set(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, Long.toString(H)); + set(PqAnchorConfig.PROPERTY_MIN_SEALS, H + ":3," + H + ":5"); + assertRefusesNaming(PqAnchorConfig.PROPERTY_MIN_SEALS); + } + + // =============================================================================================== + // 5. THE BRAKE. It is the one control that has to work when the chain is already stopped, so it + // is the one place where "refuse to start" would be the wrong answer. + // =============================================================================================== + + /** P9: Boolean.parseBoolean made "1", "yes" and "on" into a silent FALSE. A pressed brake. */ + @Test + public void p9_theBrakeUnderstandsTheWordsAnOperatorActuallyTypes() { + for (final String yes : new String[] {"true", "TRUE", "1", "yes", "YES", "on", "On"}) { + installReader(); + armCorrectly(); + set(PqAnchorConfig.PROPERTY_DISABLE, yes); + final PqAnchorConfig config = PqAnchorConfig.fromSystemConfiguration(); + assertThat(config.disabled()).as("disarm value %s", yes).isTrue(); + assertThat(config.everActive()).isFalse(); + assertThat(config.activeAt(H + 1)).isFalse(); + // Still CONFIGURED, so PqEmergencyShoutRule keeps shouting at every block. + assertThat(config.anchorConfigured()).isTrue(); + assertThat(config.anchorBlock()).isEqualTo(H); + } + for (final String no : new String[] {"false", "FALSE", "0", "no", "off"}) { + installReader(); + armCorrectly(); + set(PqAnchorConfig.PROPERTY_DISABLE, no); + final PqAnchorConfig config = PqAnchorConfig.fromSystemConfiguration(); + assertThat(config.disabled()).as("disarm value %s", no).isFalse(); + assertThat(config.everActive()).isTrue(); + } + } + + /** A brake value nobody can read is the one thing worse than no brake: refuse. */ + @Test + public void p9_anUnreadableBrakeValueIsRefused() { + armCorrectly(); + set(PqAnchorConfig.PROPERTY_DISABLE, "maybe"); + assertRefusesNaming(PqAnchorConfig.PROPERTY_DISABLE); + } + + /** + * THE BRAKE IS ABSOLUTE. A node whose anchor configuration is broken in every other way must + * still start when the operator has asked for the anchor to be off, because that request is the + * only way back from an activation that stopped the chain. + */ + @Test + public void theBrakeStartsTheNodeEvenOverAConfigurationThatWouldOtherwiseBeRefused() { + set(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, ""); + set(PqAnchorConfig.PROPERTY_MIN_SEALS, "this,is,not,a,schedule"); + set(PqAnchorConfig.PROPERTY_MIN_SEALS_CEILING, "-9"); + set(PqAnchorConfig.PROPERTY_DISABLE, "true"); + + final PqAnchorConfig config = PqAnchorConfig.fromSystemConfiguration(); + assertThat(config.everActive()).isFalse(); + assertThat(config.activeAt(H)).isFalse(); + // Honest cost, stated here so it is not discovered later: with no readable activation height + // there is nothing for the per-block disarm announcement to name, so it is lost in this corner. + assertThat(config.anchorConfigured()).isFalse(); + } + + // =============================================================================================== + // 6. THE CONTROLS. A gate that has never been shown to open and close is not a gate. + // =============================================================================================== + + /** + * NEGATIVE CONTROL 1. Remove the guard: a reader whose presence probe answers "absent" for every + * name. Every refusal above must disappear and the loader must fall straight through to today's + * behaviour. If any of these still threw, the refusal would be coming from somewhere other than + * the presence gate and this whole file would be measuring the wrong thing. + */ + @Test + public void negativeControl_withThePresenceGateRemovedEveryRefusalDisappears() { + final String[][] brokenConfigurations = { + {PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, ""}, + {PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, "10_141_734"}, + {PqAnchorConfig.PROPERTY_MIN_SEALS, H + ":3," + (H + 7200) + ":"}, + {PqAnchorConfig.PROPERTY_MIN_SEALS, H + ":3,," + (H + 7200) + ":4"}, + {PqAnchorConfig.PROPERTY_MIN_SEALS, (H * 10) + ":3"}, + {PqAnchorConfig.PROPERTY_MIN_SEALS_CEILING, "-1"}, + {PqAnchorConfig.PROPERTY_MIN_SEALS_CEILING, "three"}, + {PqAnchorConfig.PROPERTY_CHAIN_ID, "0"}, + {PqAnchorConfig.PROPERTY_DISABLE, "maybe"} + }; + + // First, with the gate in place, prove each one is genuinely red. A negative control over cases + // that were never red proves nothing at all. + for (final String[] broken : brokenConfigurations) { + installReader(); + set(PqAnchorConfig.PROPERTY_CHAIN_ID, "2800"); + set(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, Long.toString(H)); + set(PqAnchorConfig.PROPERTY_MIN_SEALS, H + ":3"); + set(broken[0], broken[1]); + assertThatThrownBy(PqAnchorConfig::fromSystemConfiguration) + .as("with the gate in place: %s=%s", broken[0], broken[1]) + .isInstanceOf(FalconSealSupport.ActivationConfigException.class); + } + + // Now pull the guard out and watch every one of them go quiet. + PqAnchorConfig.useNameReaderForTesting(new BlindReader()); + for (final String[] broken : brokenConfigurations) { + assertThatCode(PqAnchorConfig::fromSystemConfiguration) + .as("with the gate removed: %s=%s", broken[0], broken[1]) + .doesNotThrowAnyException(); + assertThat(PqAnchorConfig.fromSystemConfiguration().everActive()) + .as("with the gate removed: %s=%s", broken[0], broken[1]) + .isFalse(); + } + } + + /** + * NEGATIVE CONTROL 2, the sharper one. For every refusal above, the SAME configuration with only + * the one broken field corrected must start cleanly. Without this, a loader that refused + * absolutely everything would score full marks on the nine cases above. + */ + @Test + public void negativeControl_correctingOnlyTheBrokenFieldMakesEachCaseStart() { + // field, the value that must refuse, the value that must start. + final String[][] pairs = { + {PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, "", Long.toString(H)}, + {PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, "10_141_734", Long.toString(H)}, + {PqAnchorConfig.PROPERTY_MIN_SEALS, H + ":3," + (H + 7200) + ":", H + ":3," + (H + 7200) + ":4"}, + {PqAnchorConfig.PROPERTY_MIN_SEALS, H + ":3,," + (H + 7200) + ":4", H + ":3," + (H + 7200) + ":4"}, + {PqAnchorConfig.PROPERTY_MIN_SEALS, (H * 10) + ":3", H + ":3," + (H * 10) + ":4"}, + {PqAnchorConfig.PROPERTY_MIN_SEALS_CEILING, "-1", "2"}, + {PqAnchorConfig.PROPERTY_MIN_SEALS_CEILING, "three", "2"}, + {PqAnchorConfig.PROPERTY_CHAIN_ID, "0", "2800"}, + {PqAnchorConfig.PROPERTY_DISABLE, "maybe", "false"} + }; + for (final String[] pair : pairs) { + installReader(); + armCorrectly(); + set(pair[0], pair[1]); + assertThatThrownBy(PqAnchorConfig::fromSystemConfiguration) + .as("broken: %s=%s", pair[0], pair[1]) + .isInstanceOf(FalconSealSupport.ActivationConfigException.class); + + installReader(); + armCorrectly(); + set(pair[0], pair[2]); + final PqAnchorConfig config = PqAnchorConfig.fromSystemConfiguration(); + assertThat(config.everActive()).as("corrected: %s=%s", pair[0], pair[2]).isTrue(); + assertThat(config.anchorBlock()).isEqualTo(H); + } + } + + /** + * POSITIVE CONTROL for the refusal message itself. The message is the whole product here: an + * operator at three in the morning gets one screen, and it has to name the field, the value read, + * the problem, the repair, and the sequencing rule that keeps a fleet restart from killing the + * chain. + * + *

The sequencing rule is asserted as a RULE, not as a count. Until 2026-08-29 this test pinned + * the literal phrase "At quorum 5 of 7 you lose the chain", which had been false since the set + * grew to nine on 2026-08-12: the message, and this test with it, carried the fleet of a world + * three weeks gone. A message that names today's set size is wrong on the day it changes, and the + * test that pins it makes the wrongness load-bearing. + */ + @Test + public void theRefusalMessageCarriesEverythingAnOperatorNeedsAtThreeInTheMorning() { + set(PqAnchorConfig.PROPERTY_CHAIN_ID, "2800"); + set(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, Long.toString(H)); + set(PqAnchorConfig.PROPERTY_MIN_SEALS, H + ":3," + (H + 7200) + ":"); + + assertThatThrownBy(PqAnchorConfig::fromSystemConfiguration) + .isInstanceOf(FalconSealSupport.ActivationConfigException.class) + .hasMessageContaining(PqAnchorConfig.REFUSAL_CODE) + .hasMessageContaining("FIELD " + PqAnchorConfig.PROPERTY_MIN_SEALS) + .hasMessageContaining("SOURCE system property (BESU_OPTS)") + .hasMessageContaining("READ \"" + H + ":3," + (H + 7200) + ":\"") + .hasMessageContaining("PROBLEM step \"" + (H + 7200) + ":\" has no threshold after the ':'") + .hasMessageContaining("EXPECTED block:threshold,block:threshold") + .hasMessageContaining("a step exactly at " + H) + .hasMessageContaining("FIX correct BESU_OPTS on THIS node") + .hasMessageContaining("restart one at a time") + .hasMessageContaining("never in parallel") + .hasMessageContaining("more than f") + .hasMessageContaining("EMERGENCY " + PqAnchorConfig.PROPERTY_DISABLE + "=true"); + } + + /** And the origin is named honestly when the value came from the environment, not the command. */ + @Test + public void theRefusalNamesTheEnvironmentWhenThatIsWhereTheValueCameFrom() { + setEnvironment(PqAnchorConfig.ENV_CHAIN_ID, "2800"); + setEnvironment(PqAnchorConfig.ENV_ANCHOR_BLOCK, "not-a-number"); + assertThatThrownBy(PqAnchorConfig::fromSystemConfiguration) + .hasMessageContaining("SOURCE environment variable " + PqAnchorConfig.ENV_ANCHOR_BLOCK); + } + // ---- D-336: the interval schedule ------------------------------------------------------------- + + private static PqAnchorConfig every32() { + return new PqAnchorConfig(2800L, 1000L, schedule(), OptionalInt.empty(), false) + .withAnchorInterval(OptionalInt.of(32)); + } + + @Test + public void aScheduledIntervalKeepsTheOldGridBelowTheHeightAndThinsItAbove() { + // 1384 = 1000 + 3 * 128: on the new grid AND an old-grid anchor (384 % 32 == 0) + final PqAnchorConfig c = every32().withAnchorIntervalSchedule(java.util.Map.of(1384L, 128)); + assertThat(c.intervalAt(1383L)).isEqualTo(32); + assertThat(c.intervalAt(1384L)).isEqualTo(128); + assertThat(c.isAnchorHeight(1032L)).isTrue(); + assertThat(c.isAnchorHeight(1048L)).isFalse(); + assertThat(c.isAnchorHeight(1352L)).isTrue(); // last old-grid anchor before the change + assertThat(c.isAnchorHeight(1384L)).isTrue(); // the activation height is itself an anchor + assertThat(c.isAnchorHeight(1416L)).isFalse(); // 1384 + 32: no longer an anchor + assertThat(c.isAnchorHeight(1512L)).isTrue(); // 1384 + 128 + // every anchor of the new regime was an anchor of the old one + for (long h = 1384L; h < 1384L + 128L * 20; h++) { + if (c.isAnchorHeight(h)) { + assertThat(every32().isAnchorHeight(h)).isTrue(); + } + } + } + + @Test + public void aScheduleHeightOffTheNewGridIsRefused() { + assertThatThrownBy(() -> every32().withAnchorIntervalSchedule(java.util.Map.of(1400L, 128))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("not on the new grid"); + } + + @Test + public void anIntervalThatIsNotAMultipleOfTheBaseIsRefused() { + assertThatThrownBy(() -> every32().withAnchorIntervalSchedule(java.util.Map.of(1400L, 100))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("not a positive multiple"); + } + + @Test + public void aScheduleWithoutABaseIntervalIsRefused() { + final PqAnchorConfig everyBlock = new PqAnchorConfig(2800L, 1000L, schedule(), OptionalInt.empty(), false); + assertThatThrownBy(() -> everyBlock.withAnchorIntervalSchedule(java.util.Map.of(1384L, 128))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("cannot create one"); + } + +} diff --git a/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorEmergencyConfigTest.java b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorEmergencyConfigTest.java new file mode 100755 index 000000000..67fb25b76 --- /dev/null +++ b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorEmergencyConfigTest.java @@ -0,0 +1,120 @@ +/* + * Copyright contributors to Besu / AERE Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Map; +import java.util.OptionalInt; + +import org.junit.jupiter.api.Test; + +/** + * AERE OPTIUNI-URGENTA: the arithmetic the two anchor emergency controls stand on. + * + *

The property that matters is not "the ceiling works". It is that the ceiling can only ever go + * ONE WAY. A control an operator reaches for at three in the morning to restart a stalled chain must + * not be capable of stopping one, so a ceiling ABOVE the scheduled threshold has to be inert rather + * than raising it. That is asserted here in both directions, and the announcement predicate is + * asserted alongside it, because a shout that fires when the control is doing nothing trains people + * to ignore the log. + */ +class PqAnchorEmergencyConfigTest { + + private static final long CHAIN_ID = 2800L; + private static final long H = 1_000L; + + private PqAnchorConfig config(final OptionalInt ceiling, final boolean disabled) { + return new PqAnchorConfig( + CHAIN_ID, H, Map.of(H, 0, H + 100L, 3, H + 200L, 5), ceiling, disabled); + } + + @Test + void ceilingLowersTheThreshold() { + final PqAnchorConfig capped = config(OptionalInt.of(1), false); + assertThat(capped.scheduledMinSealsAt(H + 250L)).isEqualTo(5); + assertThat(capped.minSealsAt(H + 250L)).isEqualTo(1); + } + + @Test + void ceilingCanNeverRaiseTheThreshold() { + final PqAnchorConfig capped = config(OptionalInt.of(99), false); + // Scheduled 0, 3 and 5 at the three steps; a ceiling of 99 must change NONE of them. + assertThat(capped.minSealsAt(H)).isEqualTo(0); + assertThat(capped.minSealsAt(H + 100L)).isEqualTo(3); + assertThat(capped.minSealsAt(H + 250L)).isEqualTo(5); + assertThat(capped.minSealsAt(H + 250L)).isEqualTo(capped.scheduledMinSealsAt(H + 250L)); + } + + @Test + void ceilingOfZeroRemovesTheThresholdEntirely() { + final PqAnchorConfig capped = config(OptionalInt.of(0), false); + assertThat(capped.minSealsAt(H + 250L)).isZero(); + } + + @Test + void theAnnouncementFiresOnlyWhereTheCeilingActuallyLowersSomething() { + final PqAnchorConfig capped = config(OptionalInt.of(3), false); + // Below H nothing is active at all, so there is nothing to announce. + assertThat(capped.emergencyCeilingLowersAt(H - 1L)).isFalse(); + // At H the schedule asks for 0; a ceiling of 3 is carrying no weight there. + assertThat(capped.emergencyCeilingLowersAt(H)).isFalse(); + // At H+100 the schedule asks for exactly 3; still no weight. + assertThat(capped.emergencyCeilingLowersAt(H + 100L)).isFalse(); + // At H+200 the schedule asks for 5 and the ceiling holds it at 3. THAT is worth a line. + assertThat(capped.emergencyCeilingLowersAt(H + 250L)).isTrue(); + } + + @Test + void noCeilingMeansNoAnnouncementAnywhere() { + final PqAnchorConfig plain = config(OptionalInt.empty(), false); + assertThat(plain.emergencyCeilingLowersAt(H + 250L)).isFalse(); + assertThat(plain.minSealsCeiling()).isEmpty(); + } + + @Test + void disarmedIsNotTheSameStateAsNeverConfigured() { + final PqAnchorConfig disarmed = config(OptionalInt.empty(), true); + assertThat(disarmed.disabled()).isTrue(); + assertThat(disarmed.anchorConfigured()).isTrue(); + assertThat(disarmed.everActive()).isFalse(); + assertThat(disarmed.activeAt(H + 500L)).isFalse(); + + final PqAnchorConfig never = PqAnchorConfig.never(CHAIN_ID); + assertThat(never.disabled()).isFalse(); + assertThat(never.anchorConfigured()).isFalse(); + assertThat(never.everActive()).isFalse(); + } + + @Test + void aDisarmedNodeStillEnforcesNothingEvenWhereTheScheduleIsHighest() { + final PqAnchorConfig disarmed = config(OptionalInt.empty(), true); + assertThat(disarmed.activeAt(H + 250L)).isFalse(); + // The announcement predicate is gated on activeAt, so a disarmed node announces the DISARM and + // not, additionally, a ceiling that is no longer reachable. + assertThat(disarmed.emergencyCeilingLowersAt(H + 250L)).isFalse(); + } + + @Test + void aNegativeCeilingIsRefusedRatherThanClampedSilently() { + assertThatThrownBy( + () -> + new PqAnchorConfig( + CHAIN_ID, H, Map.of(H, 3), OptionalInt.of(-1), false)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("must not be negative"); + } +} diff --git a/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorIntervalTest.java b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorIntervalTest.java new file mode 100755 index 000000000..d1cd9fdbb --- /dev/null +++ b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorIntervalTest.java @@ -0,0 +1,184 @@ +/* + * AERE, 2026-08-07. The anchor interval: a certificate on every Nth block instead of every block. + * + * WHY THIS EXISTS. At ~523 ms per block we produce 165,248 blocks per day, 23 times more than + * Ethereum. A certificate in EVERY block costs 120.5 GB per year per node even with the cap at K=3. + * The fleet's disks are 38 and 75 GB, so the design does not fit anywhere. + * + * WHY IT IS SAFE, and this is the argument that has to hold, not the saving. Block hashes chain: + * block N+1 commits to the hash of N. So an anchor at height A, whose vanityData binds a Falcon + * certificate over A-1, protects EVERYTHING below A: rewriting any block below A changes the hash + * of A's parent, which requires A to be reproduced, which requires a Falcon quorum the adversary + * does not have. The only thing that stays rewritable is the TAIL since the last anchor. The + * property is therefore + * + * fork depth <= interval + * + * and it is a dial, not an accident. At 100 it means ~52 seconds of tail instead of ~0.5 seconds, + * and it costs one hundredth as much. Algorand ships the same shape at 1 in 256. + */ +package org.hyperledger.besu.consensus.common.bft; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Map; +import java.util.OptionalInt; +import org.junit.jupiter.api.Test; + +class PqAnchorIntervalTest { + + private static final long H = 1000L; + + private static PqAnchorConfig armed() { + return new PqAnchorConfig(2800L, H, Map.of(H, 3), OptionalInt.empty(), false); + } + + // --------------------------------------------------------------------------------------------- + // 1. THE COMPATIBILITY BOUNDARY. Unset means every block, that is the design as it stood until + // today. If this ever breaks, a node built from the new code no longer agrees with an old one. + // --------------------------------------------------------------------------------------------- + @Test + void withNoIntervalEveryHeightIsAnAnchorHeight() { + final PqAnchorConfig cfg = armed(); + assertThat(cfg.anchorInterval()).isEmpty(); + for (final long n : new long[] {H, H + 1, H + 2, H + 99, H + 100, H + 12345}) { + assertThat(cfg.isAnchorHeight(n)).as("height %d", n).isTrue(); + assertThat(cfg.anchorAppliesAt(n)).as("height %d", n).isTrue(); + } + } + + // --------------------------------------------------------------------------------------------- + // 2. An interval of 1 is the same thing written out explicitly, and must behave identically. + // --------------------------------------------------------------------------------------------- + @Test + void anIntervalOfOneIsTheSameAsNoInterval() { + final PqAnchorConfig cfg = armed().withAnchorInterval(OptionalInt.of(1)); + for (final long n : new long[] {H, H + 1, H + 7, H + 1000}) { + assertThat(cfg.isAnchorHeight(n)).as("height %d", n).isTrue(); + } + } + + // --------------------------------------------------------------------------------------------- + // 3. THE HEART. Counting starts at H, not at zero. H is the only height the fleet coordinates on, + // and a scheme whose first anchor falls somewhere else is a scheme nobody checks by hand on + // activation day. + // --------------------------------------------------------------------------------------------- + @Test + void anchorHeightsAreCountedFromHAndHItselfIsAlwaysOne() { + final PqAnchorConfig cfg = armed().withAnchorInterval(OptionalInt.of(100)); + + assertThat(cfg.isAnchorHeight(H)).as("H itself").isTrue(); + assertThat(cfg.isAnchorHeight(H + 100)).isTrue(); + assertThat(cfg.isAnchorHeight(H + 200)).isTrue(); + assertThat(cfg.isAnchorHeight(H + 100000)).isTrue(); + + for (final long off : new long[] {1, 2, 50, 99, 101, 199}) { + assertThat(cfg.isAnchorHeight(H + off)).as("H+%d is not an anchor", off).isFalse(); + assertThat(cfg.anchorAppliesAt(H + off)).as("H+%d is not judged", off).isFalse(); + } + } + + // --------------------------------------------------------------------------------------------- + // 4. Below H there is no anchor, however neatly the arithmetic falls. Without this, a height + // below H that happens to be a multiple would be judged, and a new binary would no longer + // behave like an old one on the history from before activation. + // --------------------------------------------------------------------------------------------- + @Test + void belowHNothingIsAnAnchorHeightHoweverTheArithmeticFalls() { + final PqAnchorConfig cfg = armed().withAnchorInterval(OptionalInt.of(100)); + for (final long n : new long[] {0L, 1L, H - 200, H - 100, H - 1}) { + assertThat(cfg.isAnchorHeight(n)).as("height %d", n).isFalse(); + assertThat(cfg.anchorAppliesAt(n)).as("height %d", n).isFalse(); + } + } + + // --------------------------------------------------------------------------------------------- + // 5. NEGATIVE CONTROL. Zero and negative are refused. An interval of 0 would give a division by + // zero, and a negative one would make the modulo arithmetic answer nonsense. + // --------------------------------------------------------------------------------------------- + @Test + void zeroAndNegativeIntervalsAreRefused() { + for (final int bad : new int[] {0, -1, -100}) { + assertThatThrownBy(() -> armed().withAnchorInterval(OptionalInt.of(bad))) + .as("interval %d", bad) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining(PqAnchorConfig.PROPERTY_ANCHOR_INTERVAL) + .hasMessageContaining("at least 1"); + } + } + + // --------------------------------------------------------------------------------------------- + // 6. anchorAppliesAt is the AND of the two conditions, and the emergency disarm beats all of it. + // A disarm that let the anchor judge at the interval heights would not be a disarm. + // --------------------------------------------------------------------------------------------- + @Test + void theEmergencyDisarmBeatsTheIntervalEverywhere() { + final PqAnchorConfig dezarmat = + new PqAnchorConfig(2800L, H, Map.of(H, 3), OptionalInt.empty(), true) + .withAnchorInterval(OptionalInt.of(100)); + assertThat(dezarmat.isAnchorHeight(H)).isTrue(); // the arithmetic says yes + assertThat(dezarmat.activeAt(H)).isFalse(); // but the scheme is switched off + assertThat(dezarmat.anchorAppliesAt(H)).isFalse(); // so nothing is judged + assertThat(dezarmat.anchorAppliesAt(H + 100)).isFalse(); + } + + // --------------------------------------------------------------------------------------------- + // 7. A node with no anchor at all has no anchor heights either. Without this, a configuration + // with no H but with an interval would answer "yes" to anything and would judge headers on a + // chain that was never activated. + // --------------------------------------------------------------------------------------------- + @Test + void aNodeWithNoAnchorAtAllHasNoAnchorHeights() { + final PqAnchorConfig never = PqAnchorConfig.never(2800L).withAnchorInterval(OptionalInt.of(100)); + for (final long n : new long[] {0L, 1L, H, H + 100, Long.MAX_VALUE - 1}) { + assertThat(never.isAnchorHeight(n)).as("height %d", n).isFalse(); + assertThat(never.anchorAppliesAt(n)).as("height %d", n).isFalse(); + } + } + + // --------------------------------------------------------------------------------------------- + // 8. THE TWO COST CONTROLS DO NOT LOSE EACH OTHER. This is the mistake I made a point of + // catching: a copy method that forgets a field is how the cap or the interval would silently + // disappear when the other one is set, and nobody would notice until the disk bill. + // --------------------------------------------------------------------------------------------- + @Test + void theTwoCostControlsDoNotLoseEachOther() { + final PqAnchorConfig amandoua = + armed().withMaxSealsCarried(OptionalInt.of(3)).withAnchorInterval(OptionalInt.of(100)); + assertThat(amandoua.maxSealsCarried()).hasValue(3); + assertThat(amandoua.anchorInterval()).hasValue(100); + + // and in the reverse order + final PqAnchorConfig invers = + armed().withAnchorInterval(OptionalInt.of(100)).withMaxSealsCarried(OptionalInt.of(3)); + assertThat(invers.maxSealsCarried()).hasValue(3); + assertThat(invers.anchorInterval()).hasValue(100); + + // and the rest of the configuration survives both of them + assertThat(invers.chainId()).isEqualTo(2800L); + assertThat(invers.anchorBlock()).isEqualTo(H); + assertThat(invers.minSealsAt(H)).isEqualTo(3); + } + + // --------------------------------------------------------------------------------------------- + // 9. THE PROPERTY, written as a proof and not merely as a sentence in a comment: between two + // consecutive anchors there are exactly heights, of which exactly one is judged. + // That IS the maximum fork depth, and we count it instead of asserting it. + // --------------------------------------------------------------------------------------------- + @Test + void betweenTwoAnchorsExactlyOneHeightIsJudged() { + final int interval = 100; + final PqAnchorConfig cfg = armed().withAnchorInterval(OptionalInt.of(interval)); + int judecate = 0; + for (long n = H; n < H + interval; n++) { + if (cfg.anchorAppliesAt(n)) { + judecate++; + } + } + assertThat(judecate) + .as("exactly one height judged every %d, so the rewritable tail is %d blocks", + interval, interval) + .isEqualTo(1); + } +} diff --git a/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorLapseTest.java b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorLapseTest.java new file mode 100755 index 000000000..038d86287 --- /dev/null +++ b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorLapseTest.java @@ -0,0 +1,249 @@ +/* + * Copyright contributors to Besu / Aere Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +/** + * The named historical ranges in which the anchor rules were not fully in force. + * + *

The bounds are the whole content of the class under test, so they are asserted as LITERALS + * rather than through the constants they define. An assertion written as {@code + * assertThat(FIRST).isEqualTo(FIRST)} would follow any future edit of the constant and would + * therefore never fail, which is the exact shape of a gate that cannot go red. + */ +public class PqAnchorLapseTest { + + private static final long LAST_ENFORCED_BEFORE = 13_267_792L; + + private static final long DISARMED_FIRST = 13_267_824L; + private static final long DISARMED_LAST = 13_268_944L; + + private static final long LOWERED_FIRST = 13_268_976L; + private static final long LOWERED_LAST = 13_890_544L; + + private static final long FIRST_FULLY_ENFORCED_AFTER = 13_890_576L; + + @Test + public void theListNamesExactlyTheTwoMeasuredRanges() { + // Measured by reading vanityData and the certificate at every anchor height from the activation + // height 13,014,000 to the head at the time of measurement, 14,077,000, one height at a time. + // The list is a LIST so a third could be added without touching a rule; it has two because the + // chain has two. + assertThat(PqAnchorLapse.windows()).hasSize(2); + + final PqAnchorLapse.Window disarmed = PqAnchorLapse.windows().get(0); + assertThat(disarmed.firstBlock()).isEqualTo(13_267_824L); + assertThat(disarmed.lastBlock()).isEqualTo(13_268_944L); + assertThat(disarmed.anchorHeights()).isEqualTo(36L); + assertThat(disarmed.relaxation()).isEqualTo(PqAnchorLapse.Relaxation.EVERYTHING); + assertThat(disarmed.effectiveMinSeals()).isZero(); + assertThat(disarmed.reason()).contains("2026-08-10"); + + final PqAnchorLapse.Window lowered = PqAnchorLapse.windows().get(1); + assertThat(lowered.firstBlock()).isEqualTo(13_268_976L); + assertThat(lowered.lastBlock()).isEqualTo(13_890_544L); + assertThat(lowered.anchorHeights()).isEqualTo(19_425L); + assertThat(lowered.relaxation()).isEqualTo(PqAnchorLapse.Relaxation.THRESHOLD_ONLY); + assertThat(lowered.effectiveMinSeals()).isEqualTo(1); + } + + @Test + public void theTwoRangesAreAdjacentButDisjoint() { + // The second begins at the next anchor height after the first ends. They must not overlap, and + // there must be no unnamed anchor height between them, because a height in neither range is + // judged strictly and there is no such height here. + assertThat(DISARMED_LAST + PqAnchorLapse.ANCHOR_SPACING).isEqualTo(LOWERED_FIRST); + assertThat(PqAnchorLapse.windows().get(0).lastBlock()) + .isLessThan(PqAnchorLapse.windows().get(1).firstBlock()); + } + + @Test + public void theStatedSpansAgreeWithTheBounds() { + // 36, not 37: (13,268,944 - 13,267,824) / 32 + 1 = 36. And 19,425 for the second range. The + // span is carried in each entry so the two cannot drift apart in silence; this asserts the + // arithmetic from the outside as well. + for (final PqAnchorLapse.Window window : PqAnchorLapse.windows()) { + assertThat((window.lastBlock() - window.firstBlock()) % PqAnchorLapse.ANCHOR_SPACING).isZero(); + assertThat((window.lastBlock() - window.firstBlock()) / PqAnchorLapse.ANCHOR_SPACING + 1L) + .isEqualTo(window.anchorHeights()); + } + } + + // ----------------------------------------------------------------------------------------------- + // isDisarmed: TRUE only in the first range, and deliberately FALSE in the second. + // ----------------------------------------------------------------------------------------------- + + @Test + public void isDisarmedIsTrueOnlyWhereNothingWasInForce() { + assertThat(PqAnchorLapse.isDisarmed(DISARMED_FIRST)).isTrue(); + assertThat(PqAnchorLapse.isDisarmed(DISARMED_LAST)).isTrue(); + + // The neighbouring anchor heights, both measured to carry a digest, stay under the strict rules. + assertThat(PqAnchorLapse.isDisarmed(LAST_ENFORCED_BEFORE)).isFalse(); + assertThat(PqAnchorLapse.isDisarmed(DISARMED_FIRST - 1L)).isFalse(); + assertThat(PqAnchorLapse.isDisarmed(DISARMED_LAST + 1L)).isFalse(); + + // And FALSE across the whole second range: there the digest and the ordering did hold, and a + // relaxation that leaked into it would throw away the binding on 19,425 anchor heights. + assertThat(PqAnchorLapse.isDisarmed(LOWERED_FIRST)).isFalse(); + assertThat(PqAnchorLapse.isDisarmed(13_500_016L)).isFalse(); + assertThat(PqAnchorLapse.isDisarmed(LOWERED_LAST)).isFalse(); + assertThat(PqAnchorLapse.isDisarmed(FIRST_FULLY_ENFORCED_AFTER)).isFalse(); + } + + @Test + public void everyAnchorHeightOfTheDisarmedRangeIsCovered() { + long covered = 0L; + for (long height = DISARMED_FIRST; + height <= DISARMED_LAST; + height += PqAnchorLapse.ANCHOR_SPACING) { + assertThat(PqAnchorLapse.isDisarmed(height)) + .describedAs("anchor height %d of the disarmed range", height) + .isTrue(); + covered++; + } + assertThat(covered).isEqualTo(36L); + + // The rest of the chain is untouched. + assertThat(PqAnchorLapse.isDisarmed(0L)).isFalse(); + assertThat(PqAnchorLapse.isDisarmed(13_014_000L)).isFalse(); + assertThat(PqAnchorLapse.isDisarmed(13_000_000L)).isFalse(); + assertThat(PqAnchorLapse.isDisarmed(14_077_000L)).isFalse(); + assertThat(PqAnchorLapse.isDisarmed(Long.MAX_VALUE)).isFalse(); + } + + // ----------------------------------------------------------------------------------------------- + // historicMinSeals: the threshold that was really in force, and only where it was lower. + // ----------------------------------------------------------------------------------------------- + + @Test + public void historicMinSealsAnswersOnlyInsideTheLoweredRange() { + assertThat(PqAnchorLapse.historicMinSeals(LOWERED_FIRST)).hasValue(1); + assertThat(PqAnchorLapse.historicMinSeals(13_500_016L)).hasValue(1); + assertThat(PqAnchorLapse.historicMinSeals(LOWERED_LAST)).hasValue(1); + + // One anchor step past the end and the configured schedule applies again, unchanged. This is + // the assertion that stops the lowered threshold from becoming permanent. + assertThat(PqAnchorLapse.historicMinSeals(FIRST_FULLY_ENFORCED_AFTER)).isEmpty(); + assertThat(PqAnchorLapse.historicMinSeals(LOWERED_LAST + 1L)).isEmpty(); + assertThat(PqAnchorLapse.historicMinSeals(LOWERED_FIRST - 1L)).isEmpty(); + assertThat(PqAnchorLapse.historicMinSeals(LAST_ENFORCED_BEFORE)).isEmpty(); + assertThat(PqAnchorLapse.historicMinSeals(14_077_000L)).isEmpty(); + assertThat(PqAnchorLapse.historicMinSeals(Long.MAX_VALUE)).isEmpty(); + + // And it does NOT answer inside the disarmed range: there the rules are skipped whole, so a + // threshold would be a second, contradictory way of saying the same thing. + assertThat(PqAnchorLapse.historicMinSeals(DISARMED_FIRST)).isEmpty(); + assertThat(PqAnchorLapse.historicMinSeals(DISARMED_LAST)).isEmpty(); + } + + @Test + public void theFloorInsideTheLoweredRangeIsOneAndNotZero() { + // Measured: 2,926 of the 19,425 anchor heights in that range carry fewer seals than the + // schedule asks, the counts seen are 1 and 2, and NOT ONE carries zero. A floor of zero would + // therefore accept an empty certificate at a height where a certificate was in fact required, + // which is weaker than the history needs. + assertThat(PqAnchorLapse.historicMinSeals(LOWERED_FIRST)).hasValue(1); + assertThat(PqAnchorLapse.windows().get(1).effectiveMinSeals()).isPositive(); + } + + @Test + public void windowCoveringNamesTheRangeOrNothing() { + assertThat(PqAnchorLapse.windowCovering(DISARMED_FIRST)).isPresent(); + assertThat(PqAnchorLapse.windowCovering(DISARMED_FIRST).get().lastBlock()) + .isEqualTo(DISARMED_LAST); + assertThat(PqAnchorLapse.windowCovering(LOWERED_FIRST)).isPresent(); + assertThat(PqAnchorLapse.windowCovering(LOWERED_FIRST).get().relaxation()) + .isEqualTo(PqAnchorLapse.Relaxation.THRESHOLD_ONLY); + assertThat(PqAnchorLapse.windowCovering(FIRST_FULLY_ENFORCED_AFTER)).isEmpty(); + assertThat(PqAnchorLapse.earliestAffectedBlock()).contains(DISARMED_FIRST); + } + + @Test + public void theDisarmedRangeIsTheSameOBJECTTheOrderingExceptionUses() { + // A second copy of these bounds anywhere else is a value that diverges. PqAnchor used to carry + // them as its own literals; it now derives them, and this is the assertion that says so. + assertThat(PqAnchor.UNORDERED_WINDOW_FIRST).isEqualTo(DISARMED_FIRST); + assertThat(PqAnchor.UNORDERED_WINDOW_LAST).isEqualTo(DISARMED_LAST); + } + + // ----------------------------------------------------------------------------------------------- + // The self-checks can say no. A guard that has never refused anything cannot be believed. + // ----------------------------------------------------------------------------------------------- + + @Test + public void aRangeThatContradictsItselfIsRefusedRatherThanAccepted() { + assertThatThrownBy( + () -> + new PqAnchorLapse.Window( + 100L, 99L, 1L, PqAnchorLapse.Relaxation.EVERYTHING, 0, "backwards")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("non-empty range"); + assertThatThrownBy( + () -> + new PqAnchorLapse.Window( + -1L, 100L, 1L, PqAnchorLapse.Relaxation.EVERYTHING, 0, "negative")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy( + () -> + new PqAnchorLapse.Window( + 100L, 200L, 0L, PqAnchorLapse.Relaxation.EVERYTHING, 0, "no heights")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("at least one anchor height"); + assertThatThrownBy( + () -> + new PqAnchorLapse.Window( + 100L, 200L, 4L, PqAnchorLapse.Relaxation.THRESHOLD_ONLY, -1, "negative floor")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("cannot be negative"); + } + + @Test + public void aRangeCoversItsOwnBoundsAndNothingOutsideThem() { + final PqAnchorLapse.Window window = + new PqAnchorLapse.Window( + 1_000L, 1_032L, 2L, PqAnchorLapse.Relaxation.THRESHOLD_ONLY, 1, "probe"); + assertThat(window.covers(1_000L)).isTrue(); + assertThat(window.covers(1_016L)).isTrue(); + assertThat(window.covers(1_032L)).isTrue(); + assertThat(window.covers(999L)).isFalse(); + assertThat(window.covers(1_033L)).isFalse(); + assertThat(window.toString()).contains("1000").contains("1032").contains("THRESHOLD_ONLY"); + } + + @Test + public void theListIsUnmodifiable() { + // A range a node could widen at runtime would let a future lapse pass unnoticed. + final List windows = PqAnchorLapse.windows(); + assertThatThrownBy( + () -> + windows.add( + new PqAnchorLapse.Window( + 1L, 2L, 1L, PqAnchorLapse.Relaxation.EVERYTHING, 0, "injected"))) + .isInstanceOf(UnsupportedOperationException.class); + } + + @Test + public void theRangesBelongToThisChainAndAreDeclaredSo() { + assertThat(PqAnchorLapse.CHAIN_ID).isEqualTo(2800L); + assertThat(PqAnchorLapse.ANCHOR_SPACING).isEqualTo(32L); + } +} diff --git a/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorMinSealsFloorTest.java b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorMinSealsFloorTest.java new file mode 100755 index 000000000..651c63ba3 --- /dev/null +++ b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorMinSealsFloorTest.java @@ -0,0 +1,152 @@ +/* + * AERE D-147, 2026-08-07. THE THRESHOLD FLOOR: a schedule whose effective K is zero everywhere + * leaves the anchor armed and completely toothless, forever, and every tool reports GREEN the whole + * time it is happening, because they all measure what was ASKED FOR and the request is valid. + * + * The loader already guarded this consequence in its own words, "K would be 0 at every height + * and an ARMED node would accept empty certificates", but only for a MISSING schedule. A schedule + * that is PRESENT and of the form ":0" reaches the same state, and it used to pass. + * + * And it is not theoretical: PLAN-ACTIVARE recommends ":0,:3", which STARTS at zero. + * If the second half is lost to a stray quote or a truncated variable, what remains is exactly the + * dangerous form. + */ +package org.hyperledger.besu.consensus.common.bft; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.assertThatCode; + +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class PqAnchorMinSealsFloorTest { + + private static final long H = 13_793_219L; + + private static final class Reader implements PqAnchorConfig.NameReader { + private final Map properties = new HashMap<>(); + + @Override + public String property(final String name) { + return properties.get(name); + } + + @Override + public String environment(final String name) { + return null; + } + } + + private Reader reader; + + @BeforeEach + void installReader() { + reader = new Reader(); + PqAnchorConfig.useNameReaderForTesting(reader); + set(PqAnchorConfig.PROPERTY_CHAIN_ID, "2800"); + set(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, Long.toString(H)); + } + + @AfterEach + void restoreReader() { + PqAnchorConfig.useNameReaderForTesting(null); + } + + private void set(final String property, final String value) { + reader.properties.put(property, value); + } + + // --------------------------------------------------------------------------------------------- + // 1. THE DEFECT ITSELF. A schedule that never demands anything is refused at startup. + // --------------------------------------------------------------------------------------------- + @Test + void anArmedAnchorWhoseScheduleNeverDemandsASignatureIsRefused() { + set(PqAnchorConfig.PROPERTY_MIN_SEALS, H + ":0"); + + assertThatThrownBy(PqAnchorConfig::fromSystemConfiguration) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining(PqAnchorConfig.REFUSAL_MIN_SEALS_FLOOR) + .hasMessageContaining("ZERO"); + } + + // --------------------------------------------------------------------------------------------- + // 2. Nor do several zero steps add up to anything. Whoever writes two zeros believes they have + // done something. + // --------------------------------------------------------------------------------------------- + @Test + void severalZeroStepsAreStillZero() { + set(PqAnchorConfig.PROPERTY_MIN_SEALS, H + ":0," + (H + 1000) + ":0," + (H + 2000) + ":0"); + + assertThatThrownBy(PqAnchorConfig::fromSystemConfiguration) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining(PqAnchorConfig.REFUSAL_MIN_SEALS_FLOOR); + } + + // --------------------------------------------------------------------------------------------- + // 3. THE CONTROL THAT MATTERS MOST: the warm-up window stays LEGAL. A guard that also refused the + // form our own plan recommends would have been worked around within five minutes, and then we + // would have had neither the guard nor the window. + // --------------------------------------------------------------------------------------------- + @Test + void theWarmUpWindowRecommendedByOurOwnPlanStaysLegal() { + set(PqAnchorConfig.PROPERTY_MIN_SEALS, H + ":0," + (H + 165000) + ":3"); + + assertThatCode(PqAnchorConfig::fromSystemConfiguration).doesNotThrowAnyException(); + final PqAnchorConfig config = PqAnchorConfig.fromSystemConfiguration(); + assertThat(config.minSealsAt(H)).isZero(); + assertThat(config.minSealsAt(H + 165000)).isEqualTo(3); + assertThat(config.highestEffectiveMinSeals()).isEqualTo(3); + } + + @Test + void aScheduleThatDemandsFromTheStartIsFine() { + set(PqAnchorConfig.PROPERTY_MIN_SEALS, H + ":3"); + assertThatCode(PqAnchorConfig::fromSystemConfiguration).doesNotThrowAnyException(); + } + + // --------------------------------------------------------------------------------------------- + // 4. THE SECOND ROAD TO THE SAME STATE, and it is the one that would have fooled me: an + // emergency ceiling of zero drops K to zero at every height, over a schedule that looks + // perfect. The floor looks at the EFFECTIVE K, after the ceiling, precisely to catch this too. + // --------------------------------------------------------------------------------------------- + @Test + void anEmergencyCeilingOfZeroReachesTheSameToothlessStateAndIsAlsoRefused() { + set(PqAnchorConfig.PROPERTY_MIN_SEALS, H + ":3"); + set(PqAnchorConfig.PROPERTY_MIN_SEALS_CEILING, "0"); + + assertThatThrownBy(PqAnchorConfig::fromSystemConfiguration) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining(PqAnchorConfig.REFUSAL_MIN_SEALS_FLOOR) + .hasMessageContaining("emergency ceiling"); + } + + // --------------------------------------------------------------------------------------------- + // 5. THE BRAKE IS ABSOLUTE, and it stays absolute. The emergency disarm has to work when the + // chain is ALREADY stopped; a guard that refused to start a DISARMED node would be a brake + // that refuses to brake, which is exactly what our own comment says it must never be. + // --------------------------------------------------------------------------------------------- + @Test + void theEmergencyDisarmStillStartsWithAToothlessSchedule() { + set(PqAnchorConfig.PROPERTY_MIN_SEALS, H + ":0"); + set(PqAnchorConfig.PROPERTY_DISABLE, "true"); + + assertThatCode(PqAnchorConfig::fromSystemConfiguration).doesNotThrowAnyException(); + assertThat(PqAnchorConfig.fromSystemConfiguration().activeAt(H)).isFalse(); + } + + // --------------------------------------------------------------------------------------------- + // 6. A node with no anchor at all has no floor to violate. Without this, the guard would stop + // every node in the fleet that has not been configured yet, which is most of them, from + // starting on activation day. + // --------------------------------------------------------------------------------------------- + @Test + void aNodeWithNoAnchorConfiguredIsUntouched() { + PqAnchorConfig.useNameReaderForTesting(new Reader()); + assertThatCode(PqAnchorConfig::fromSystemConfiguration).doesNotThrowAnyException(); + assertThat(PqAnchorConfig.fromSystemConfiguration().everActive()).isFalse(); + } +} diff --git a/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorProducerCacheHygieneTest.java b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorProducerCacheHygieneTest.java new file mode 100644 index 000000000..7230f1eff --- /dev/null +++ b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorProducerCacheHygieneTest.java @@ -0,0 +1,104 @@ +/* + * Copyright contributors to Aere Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer; + +import java.lang.reflect.Field; +import java.nio.file.Path; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * DATED 2026-08-20. The reproduction that keeps the per-JVM anchor-config cache honest. + * + *

THE LEAK, paid for twice. {@code PqAnchorProducer.config()} memoizes the first configuration + * it builds, and that memo outlives every {@code System.clearProperty} a test class runs in its + * teardown. A class that arms the anchor through system properties and then builds {@code + * FalconSealSupport} caches an ARMED config for whichever class runs next in the same JVM. Measured + * 2026-08-11: {@code PqForkThresholdReachabilityTest} left exactly this behind and four + * PqStartupHistoryTest tests failed on a guard firing correctly; that class got the cleanup line. + * Measured 2026-08-20 on the production tree: its fork, {@code D078ThresholdReachabilityTest}, + * never received the same line, and all five {@code PqFleetRestartArmingTest} fixtures turned into + * AERE-PQC-REG-ARM-02 refusals -- green alone, red in the full suite, identical sources. + * + *

WHY THIS TEST IS SHAPED LIKE THIS. Class-order contamination is nondeterministic under + * gradle's fork assignment, so the reproduction does not rely on ordering at all: it runs the + * guilty class's OWN lifecycle (setUp, the arming test, tearDown) inside one test method, and then + * asserts the JVM is clean. If the cleanup line is ever removed from that teardown again, this + * test goes red deterministically -- that removal is exactly the planted failure it was proven + * against on the day it was written. + * + *

DATED 2026-08-31: the fork pair was consolidated -- the twin classes were one copy too many, + * and the divergence above is precisely what duplication costs. The reproduction now runs the + * lifecycle of the surviving class, {@code PqForkThresholdReachabilityTest}, which has carried the + * cleanup line since 2026-08-11; the assertion is unchanged. + */ +public class PqAnchorProducerCacheHygieneTest { + + @TempDir private Path tmp; + + @BeforeEach + public void curatInainte() throws Exception { + curata(); + } + + @AfterEach + public void curatDupa() throws Exception { + curata(); + } + + private static void curata() throws Exception { + for (final String p : System.getProperties().stringPropertyNames()) { + if (p.startsWith("aere.")) { + System.clearProperty(p); + } + } + PqAnchorProducer.useConfigForTesting(null); + final Field f = FalconSealSupport.class.getDeclaredField("instance"); + f.setAccessible(true); + f.set(null, null); + } + + @Test + public void theReachabilitySequenceLeavesNoArmedAnchorBehind() throws Exception { + final PqForkThresholdReachabilityTest vinovat = new PqForkThresholdReachabilityTest(); + final Field tmpField = PqForkThresholdReachabilityTest.class.getDeclaredField("tmp"); + tmpField.setAccessible(true); + tmpField.set(vinovat, tmp); + + vinovat.setUp(); + try { + // The exact sequence that poisons: anchor armed from properties, FalconSealSupport built. + vinovat.aReachableThresholdMustStillStart(); + } finally { + // The guilty class's OWN teardown. The assertion below is about what IT leaves behind. + vinovat.tearDown(); + } + + assertThat(PqAnchorProducer.config().everActive()) + .describedAs( + "after PqForkThresholdReachabilityTest's own teardown, a config built in this JVM must " + + "not claim an armed anchor; if it does, the per-JVM cache survived the cleanup " + + "and every proof-less fixture in the next class dies with AERE-PQC-REG-ARM-02") + .isFalse(); + } +} diff --git a/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorProducerCostTest.java b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorProducerCostTest.java new file mode 100755 index 000000000..d33767483 --- /dev/null +++ b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorProducerCostTest.java @@ -0,0 +1,250 @@ +/* + * AERE, 2026-08-07. THE TWO COST CONTROLS, measured on the PRODUCER, not on the configuration. + * + * WHY THIS EXISTS. On 7 August `aere.pq.anchor.maxSeals` and `aere.pq.anchorInterval` were built, + * and their configuration guards were proven the same day. But the cut in the producer, the code + * that ACTUALLY stops seals being written past the cap, and that ACTUALLY skips the heights with no + * anchor, stayed an ASSERTION: there was no producer harness in the tree, and D-148 had just shown + * what a piece of code that no proof touches costs. + * + * This class touches it. It counts the seals written, it does not assume them. + * + * THE NEGATIVE CONTROL IS INSIDE, and it is exactly the pair: the same seven seals heard, the same + * proposer, the same inputs, one single difference, the cap set or not. Without the half with no + * cap, a proof counting 3 would have passed even if the producer had heard only 3. + */ +package org.hyperledger.besu.consensus.common.bft; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.withSettings; + +import java.io.IOException; +import java.lang.reflect.Field; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.OptionalInt; +import org.apache.tuweni.bytes.Bytes; +import org.apache.tuweni.bytes.Bytes32; +import org.bouncycastle.crypto.digests.KeccakDigest; +import org.bouncycastle.pqc.crypto.falcon.FalconPrivateKeyParameters; +import org.bouncycastle.pqc.crypto.falcon.FalconSigner; +import org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer; +import org.hyperledger.besu.consensus.common.validator.ValidatorProvider; +import org.hyperledger.besu.datatypes.Address; +import org.hyperledger.besu.ethereum.ProtocolContext; +import org.hyperledger.besu.ethereum.core.BlockHeader; +import org.hyperledger.besu.ethereum.core.BlockHeaderTestFixture; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.quality.Strictness; + +class PqAnchorProducerCostTest { + + private static final long CHAIN_ID = 220_879L; + private static final long H = 1_000L; + private static final long ATTACH = 900L; + private static final int N = 7; + + @TempDir private Path tmp; + + private final List privateKeys = new ArrayList<>(); + private final List

validators = new ArrayList<>(); + + @BeforeEach + void setUp() throws Exception { + final KeccakDigest kd = new KeccakDigest(256); + final StringBuilder manifest = new StringBuilder(); + manifest + .append("{\"config\":{\"aereFalconRegistry\":{") + .append(PqV2Fixture.manifestHeader(N, CHAIN_ID, H)); + for (int i = 0; i < N; i++) { + privateKeys.add(PqV2Fixture.privateKey(i)); + validators.add(PqV2Fixture.address(i)); + final byte[] row = PqV2Fixture.anchorPreimageRow(i); + kd.update(row, 0, row.length); + manifest.append(',').append(PqV2Fixture.manifestEntry(i, N, CHAIN_ID, H)); + } + manifest.append("}},\"alloc\":{\"0000000000000000000000000000000000000fa1\":{\"storage\":{\"0x") + .append("0".repeat(64)) + .append("\":\"0x"); + final byte[] anchoredHash = new byte[32]; + kd.doFinal(anchoredHash, 0); + manifest.append(Bytes.wrap(anchoredHash).toUnprefixedHexString()).append("\"}}}}"); + + final Path genesis = tmp.resolve("genesis-registry.json"); + Files.writeString(genesis, manifest.toString()); + System.setProperty("aere.falcon.genesis", genesis.toAbsolutePath().toString()); + System.setProperty("aere.falcon.attachBlock", Long.toString(ATTACH)); + + resetFalconSingleton(); + PqSealCache.instance().clear(); + } + + @AfterEach + void tearDown() throws Exception { + System.clearProperty("aere.falcon.genesis"); + System.clearProperty("aere.falcon.attachBlock"); + resetFalconSingleton(); + PqSealCache.instance().clear(); + PqAnchorProducer.useConfigForTesting(null); + } + + // --------------------------------------------------------------------------------------------- + // THE CAP, measured in both directions on the same seven seals heard. + // --------------------------------------------------------------------------------------------- + + @Test + void withNoCapTheProposerWritesEverySealItHeard() { + PqAnchorProducer.useConfigForTesting(armedConfig(OptionalInt.empty(), OptionalInt.empty())); + final BlockHeader parent = parentOf(H + 10L); + hearAllSevenSealsFor(parent); + + final BftExtraData produced = PqAnchorProducer.apply(base(), parent, contextWith(validators)); + + assertThat(produced.getFalconSeals()) + .describedAs( + "K is a FLOOR: with no cap the proposer writes everything it heard, that is seven, not " + + "three. This half is what makes the number in the other half mean anything") + .hasSize(N); + } + + @Test + void withTheCapAtThreeTheProposerWritesExactlyThree() { + PqAnchorProducer.useConfigForTesting(armedConfig(OptionalInt.of(3), OptionalInt.empty())); + final BlockHeader parent = parentOf(H + 10L); + hearAllSevenSealsFor(parent); + + final BftExtraData produced = PqAnchorProducer.apply(base(), parent, contextWith(validators)); + + assertThat(produced.getFalconSeals()) + .describedAs("the same seven seals heard, the only difference is the cap") + .hasSize(3); + } + + /** + * A cap larger than what was heard does not invent seals. Without this, a badly chosen cap could + * look like it "works" merely because the number asked for happens to equal the number heard. + */ + @Test + void aCapAboveWhatWasHeardChangesNothing() { + PqAnchorProducer.useConfigForTesting(armedConfig(OptionalInt.of(20), OptionalInt.empty())); + final BlockHeader parent = parentOf(H + 10L); + hearAllSevenSealsFor(parent); + + assertThat(PqAnchorProducer.apply(base(), parent, contextWith(validators)).getFalconSeals()) + .hasSize(N); + } + + // --------------------------------------------------------------------------------------------- + // THE INTERVAL, also in both directions, and also on the same seals heard. + // --------------------------------------------------------------------------------------------- + + @Test + void atAnAnchorHeightTheProposerWritesACertificate() { + PqAnchorProducer.useConfigForTesting(armedConfig(OptionalInt.of(3), OptionalInt.of(100))); + // the parent is H-1, so the block being built is H itself, and H is always an anchor height + final BlockHeader parent = parentOf(H - 1L); + hearAllSevenSealsFor(parent); + + assertThat(PqAnchorProducer.apply(base(), parent, contextWith(validators)).getFalconSeals()) + .describedAs("H itself is always an anchor height, whatever the interval is") + .hasSize(3); + } + + @Test + void betweenAnchorsTheProposerWritesNothingEvenThoughItHeardEverything() { + PqAnchorProducer.useConfigForTesting(armedConfig(OptionalInt.of(3), OptionalInt.of(100))); + // the parent is H, so the block built is H+1, which is NOT an anchor height at interval 100 + final BlockHeader parent = parentOf(H); + hearAllSevenSealsFor(parent); + + final BftExtraData produced = PqAnchorProducer.apply(base(), parent, contextWith(validators)); + + assertThat(produced.getFalconSeals()) + .describedAs( + "seven seals heard and none written, because this height carries no certificate. " + + "This is where the hundredfold saving comes from") + .isEmpty(); + assertThat(produced.getVanityData()) + .describedAs("and the digest is not placed where there is nothing to bind") + .isEqualTo(Bytes32.ZERO); + } + + /** + * With no interval, the SAME height does carry a certificate. This is the pair of the proof + * above. + */ + @Test + void thatSameHeightDoesCarryACertificateWhenThereIsNoInterval() { + PqAnchorProducer.useConfigForTesting(armedConfig(OptionalInt.of(3), OptionalInt.empty())); + final BlockHeader parent = parentOf(H); + hearAllSevenSealsFor(parent); + + assertThat(PqAnchorProducer.apply(base(), parent, contextWith(validators)).getFalconSeals()) + .describedAs("the only difference from the proof before is the interval") + .hasSize(3); + } + + // --------------------------------------------------------------------------------------------- + // Helpers. + // --------------------------------------------------------------------------------------------- + + private static PqAnchorConfig armedConfig(final OptionalInt cap, final OptionalInt interval) { + return new PqAnchorConfig( + CHAIN_ID, H, Map.of(H, 3), OptionalInt.empty(), false, cap, interval); + } + + private static BlockHeader parentOf(final long number) { + return new BlockHeaderTestFixture().number(number).buildHeader(); + } + + private BftExtraData base() { + return new BftExtraData( + Bytes32.ZERO, Collections.emptyList(), Optional.empty(), 0, validators, Collections.emptyList()); + } + + private void hearAllSevenSealsFor(final BlockHeader parent) { + final Bytes32 m = + PqAnchor.commitMessage(CHAIN_ID, parent.getNumber(), parent.getHash().getBytes()); + final List heard = new ArrayList<>(); + for (int i = 0; i < N; i++) { + heard.add(new FalconSeal(i, Bytes.wrap(falconSign(privateKeys.get(i), m)))); + } + PqSealCache.instance().record(parent.getNumber(), parent.getHash(), heard); + } + + private static byte[] falconSign(final FalconPrivateKeyParameters key, final Bytes32 m) { + final FalconSigner signer = new FalconSigner(); + signer.init(true, key); + return signer.generateSignature(m.toArray()); + } + + private static ProtocolContext contextWith(final Collection
vs) { + final ValidatorProvider validatorProvider = + mock(ValidatorProvider.class, withSettings().strictness(Strictness.LENIENT)); + when(validatorProvider.getValidatorsForBlock(any())).thenReturn(vs); + when(validatorProvider.getValidatorsAfterBlock(any())).thenReturn(vs); + final BftContext bftContext = + mock(BftContext.class, withSettings().strictness(Strictness.LENIENT)); + when(bftContext.getValidatorProvider()).thenReturn(validatorProvider); + when(bftContext.as(any())).thenReturn(bftContext); + return new ProtocolContext.Builder().withConsensusContext(bftContext).build(); + } + + private static void resetFalconSingleton() throws Exception { + final Field f = FalconSealSupport.class.getDeclaredField("instance"); + f.setAccessible(true); + f.set(null, null); + } +} diff --git a/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorSealCapTest.java b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorSealCapTest.java new file mode 100755 index 000000000..c9bfd2ef1 --- /dev/null +++ b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorSealCapTest.java @@ -0,0 +1,150 @@ +/* + * AERE, 2026-08-07. The PROPOSER-side seal cap, and its fail-closed guard. + * + * WHY IT EXISTS. K is a FLOOR, not a cap. Measured on a live ten-node run with the threshold at 4: + * 42 blocks carried 4 seals, 36 carried 5, 5 carried 6. The proposer writes every seal it heard and + * that is eligible, not as many as the threshold demands. At 666 bytes a seal, that means 200.9 GB + * per node per year instead of 120.5, and the surplus buys nothing: what a verifier demands is THE + * THRESHOLD. + * + * WHAT THIS FILE GUARDS, and this is the dangerous part: a cap set BELOW the highest K in the + * schedule makes the proposer write certificates its own fleet rejects, at every height from the + * step that raises K above the cap onwards. The failure is SILENT to every existing tool, because + * each one measures what was ASKED FOR and the request is consistent with itself. That is why the + * node refuses to start. + */ +package org.hyperledger.besu.consensus.common.bft; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Map; +import java.util.OptionalInt; +import org.junit.jupiter.api.Test; + +class PqAnchorSealCapTest { + + private static PqAnchorConfig armed(final Map schedule, final OptionalInt ceiling) { + return new PqAnchorConfig(2800L, 100L, schedule, ceiling, false); + } + + // --------------------------------------------------------------------------------------------- + // 1. No cap means exactly what every node did before today. This is the compatibility boundary, + // and it is the first thing to break if anyone ever gives the property a default value. + // --------------------------------------------------------------------------------------------- + @Test + void withNoCapConfiguredTheProposerIsUncapped() { + final PqAnchorConfig cfg = armed(Map.of(100L, 3), OptionalInt.empty()); + assertThat(cfg.maxSealsCarried()).isEmpty(); + assertThat(cfg.maxSealsCarried().orElse(Integer.MAX_VALUE)).isEqualTo(Integer.MAX_VALUE); + } + + // --------------------------------------------------------------------------------------------- + // 2. THE NEGATIVE CONTROL. A cap below the highest K in the schedule must REFUSE, and the message + // must name both numbers, otherwise whoever reads it at 3 in the morning does not know what to + // change. + // --------------------------------------------------------------------------------------------- + @Test + void aCapBelowTheHighestScheduledThresholdIsRefused() { + final PqAnchorConfig cfg = armed(Map.of(100L, 1, 200L, 3, 300L, 5), OptionalInt.empty()); + assertThat(cfg.highestEffectiveMinSeals()).isEqualTo(5); + + assertThatThrownBy(() -> cfg.withMaxSealsCarried(OptionalInt.of(4))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining(PqAnchorConfig.REFUSAL_CODE) + .hasMessageContaining(PqAnchorConfig.PROPERTY_MAX_SEALS) + .hasMessageContaining("=4") + .hasMessageContaining("K=5"); + } + + // --------------------------------------------------------------------------------------------- + // 3. And the refusal looks at the WHOLE schedule, not just the step at the arming height. A cap + // chosen from the first step is exactly the mistake I expect: our schedule STARTS at a low K. + // --------------------------------------------------------------------------------------------- + @Test + void theRefusalLooksAtTheWholeScheduleNotJustTheFirstStep() { + // exactly the shape the activation plan recommends: starts at 0, climbs to 3 + final PqAnchorConfig cfg = armed(Map.of(100L, 0, 265L, 3), OptionalInt.empty()); + assertThat(cfg.minSealsAt(100L)).isZero(); + assertThat(cfg.highestEffectiveMinSeals()).isEqualTo(3); + + // a cap of 1 "looks" right if you only look at the first step + assertThatThrownBy(() -> cfg.withMaxSealsCarried(OptionalInt.of(1))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("K=3"); + } + + // --------------------------------------------------------------------------------------------- + // 4. A cap exactly at K is the value we want in production, and it has to be accepted. + // --------------------------------------------------------------------------------------------- + @Test + void aCapEqualToTheHighestThresholdIsAccepted() { + final PqAnchorConfig cfg = + armed(Map.of(100L, 0, 265L, 3), OptionalInt.empty()).withMaxSealsCarried(OptionalInt.of(3)); + assertThat(cfg.maxSealsCarried()).hasValue(3); + assertThat(cfg.minSealsAt(265L)).isEqualTo(3); + assertThat(cfg.anchorBlock()).isEqualTo(100L); + assertThat(cfg.chainId()).isEqualTo(2800L); + } + + @Test + void aCapAboveTheHighestThresholdIsAccepted() { + final PqAnchorConfig cfg = + armed(Map.of(100L, 3), OptionalInt.empty()).withMaxSealsCarried(OptionalInt.of(7)); + assertThat(cfg.maxSealsCarried()).hasValue(7); + } + + // --------------------------------------------------------------------------------------------- + // 5. Zero is not a cap, it is a stop. An empty certificate is a state only the SCHEDULE may ask + // for, through K=0, and only inside the warm-up window. + // --------------------------------------------------------------------------------------------- + @Test + void aCapOfZeroIsRefusedOutright() { + assertThatThrownBy( + () -> new PqAnchorConfig(2800L, 100L, Map.of(100L, 0), OptionalInt.empty(), false, OptionalInt.of(0))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining(PqAnchorConfig.PROPERTY_MAX_SEALS) + .hasMessageContaining("at least 1"); + } + + // --------------------------------------------------------------------------------------------- + // 6. The emergency ceiling LOWERS K, so it also lowers the threshold the cost cap has to reach. + // The two cannot end up arguing with each other. + // --------------------------------------------------------------------------------------------- + @Test + void theEmergencyCeilingLowersWhatTheCostCapMustReach() { + final Map schedule = Map.of(100L, 3, 200L, 5); + assertThat(armed(schedule, OptionalInt.empty()).highestEffectiveMinSeals()).isEqualTo(5); + // with the emergency ceiling at 2, the effective K never goes above 2 + final PqAnchorConfig braked = armed(schedule, OptionalInt.of(2)); + assertThat(braked.highestEffectiveMinSeals()).isEqualTo(2); + // so a cost cap of 2 becomes legal, even though the schedule says 5 + assertThat(braked.withMaxSealsCarried(OptionalInt.of(2)).maxSealsCarried()).hasValue(2); + } + + // --------------------------------------------------------------------------------------------- + // 7. An empty schedule demands nothing, so any positive cap is legal. + // --------------------------------------------------------------------------------------------- + @Test + void anEmptyScheduleDemandsNothing() { + final PqAnchorConfig cfg = armed(Map.of(), OptionalInt.empty()); + assertThat(cfg.highestEffectiveMinSeals()).isZero(); + assertThat(cfg.withMaxSealsCarried(OptionalInt.of(1)).maxSealsCarried()).hasValue(1); + } + + // --------------------------------------------------------------------------------------------- + // 8. The rest of the configuration survives the copy. A method that drops a field on the way is + // how an emergency disarm would vanish in silence the moment the cap is set. + // --------------------------------------------------------------------------------------------- + @Test + void everyOtherFieldSurvivesTheCopy() { + final PqAnchorConfig cfg = + new PqAnchorConfig(2800L, 100L, Map.of(100L, 3), OptionalInt.of(3), true) + .withMaxSealsCarried(OptionalInt.of(3)); + assertThat(cfg.chainId()).isEqualTo(2800L); + assertThat(cfg.anchorBlock()).isEqualTo(100L); + assertThat(cfg.minSealsCeiling()).hasValue(3); + assertThat(cfg.activeAt(100L)).isFalse(); // disarmed, and it stayed disarmed + assertThat(cfg.maxSealsCarried()).hasValue(3); + } +} diff --git a/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorTest.java b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorTest.java new file mode 100755 index 000000000..5bea4d37e --- /dev/null +++ b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorTest.java @@ -0,0 +1,301 @@ +/* + * Copyright contributors to Besu / AERE Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.apache.tuweni.bytes.Bytes; +import org.apache.tuweni.bytes.Bytes32; +import org.junit.jupiter.api.Test; + +/** Vectors and invariants for the V2 certificate anchor pre-images. */ +public class PqAnchorTest { + + private static final long CHAIN_ID = 2800L; + private static final Bytes32 PARENT_HASH = + Bytes32.fromHexString("0x1111111111111111111111111111111111111111111111111111111111111111"); + private static final Bytes32 OTHER_HASH = + Bytes32.fromHexString("0x2222222222222222222222222222222222222222222222222222222222222222"); + + /** + * A deterministic stand-in for a Falcon-512 signature. Real ones measure 648 to 660 bytes; the + * anchor pre-image treats them as opaque length-prefixed byte strings, so the exact length only + * has to be representative. + */ + static Bytes signatureFor(final int index, final int variant) { + final byte[] bytes = new byte[655]; + for (int i = 0; i < bytes.length; i++) { + bytes[i] = (byte) ((i * 31 + index * 7 + variant * 101) & 0xFF); + } + return Bytes.wrap(bytes); + } + + static FalconSeal seal(final int index) { + return new FalconSeal(index, signatureFor(index, 0)); + } + + @Test + public void domainLabelsAreExactlySixteenAsciiBytes() { + assertThat(PqAnchor.ANCHOR_DOMAIN.getBytes(StandardCharsets.US_ASCII)).hasSize(16); + assertThat(PqAnchor.COMMIT_DOMAIN.getBytes(StandardCharsets.US_ASCII)).hasSize(16); + assertThat(PqAnchor.ANCHOR_DOMAIN_BYTES.size()).isEqualTo(16); + assertThat(PqAnchor.COMMIT_DOMAIN_BYTES.size()).isEqualTo(16); + assertThat(PqAnchor.ANCHOR_DOMAIN).isNotEqualTo(PqAnchor.COMMIT_DOMAIN); + } + + @Test + public void anchorDigestIsThirtyTwoBytesAndDeterministic() { + final List certificate = Arrays.asList(seal(0), seal(1), seal(2)); + final Bytes32 first = PqAnchor.anchorDigest(CHAIN_ID, 10L, PARENT_HASH, certificate); + final Bytes32 second = PqAnchor.anchorDigest(CHAIN_ID, 10L, PARENT_HASH, certificate); + assertThat(first.size()).isEqualTo(32); + assertThat(first).isEqualTo(second); + } + + @Test + public void anchorDigestChangesWithEveryFieldOfItsPreImage() { + final List certificate = Arrays.asList(seal(0), seal(1), seal(2)); + final Bytes32 base = PqAnchor.anchorDigest(CHAIN_ID, 10L, PARENT_HASH, certificate); + + // chainId: this is what stops a certificate produced on the 442807 proving chain, which runs the + // same binaries and may run the same Falcon keys, from being material here. + assertThat(PqAnchor.anchorDigest(442807L, 10L, PARENT_HASH, certificate)).isNotEqualTo(base); + // parent height and parent hash: replay at another height or on another branch. + assertThat(PqAnchor.anchorDigest(CHAIN_ID, 11L, PARENT_HASH, certificate)).isNotEqualTo(base); + assertThat(PqAnchor.anchorDigest(CHAIN_ID, 10L, OTHER_HASH, certificate)).isNotEqualTo(base); + } + + @Test + public void anchorDigestDetectsAStrippedCertificate() { + // Attack A1: remove element 6 entirely. The canonical round-trip gate cannot see this, because + // truncated bytes re-encode to themselves. The digest can. + final List certificate = Arrays.asList(seal(0), seal(1), seal(2)); + assertThat(PqAnchor.anchorDigest(CHAIN_ID, 10L, PARENT_HASH, Collections.emptyList())) + .isNotEqualTo(PqAnchor.anchorDigest(CHAIN_ID, 10L, PARENT_HASH, certificate)); + } + + @Test + public void anchorDigestDetectsAnEquallyValidDifferentSubset() { + // Attack A2: swap quorum subset {0,1,2,3} for {0,1,2,4}, both authentic over the same parent. + // The block hash does not change, because the certificate is outside its pre-image. D does. + final List subsetA = Arrays.asList(seal(0), seal(1), seal(2), seal(3)); + final List subsetB = Arrays.asList(seal(0), seal(1), seal(2), seal(4)); + assertThat(PqAnchor.anchorDigest(CHAIN_ID, 10L, PARENT_HASH, subsetA)) + .isNotEqualTo(PqAnchor.anchorDigest(CHAIN_ID, 10L, PARENT_HASH, subsetB)); + } + + @Test + public void anchorDigestDetectsAReorderedCertificate() { + // Attack A3, first of the two independent refusals. The second is the strictly increasing index + // requirement, which both rules apply. + final List ordered = Arrays.asList(seal(0), seal(1)); + final List reordered = Arrays.asList(seal(1), seal(0)); + assertThat(PqAnchor.anchorDigest(CHAIN_ID, 10L, PARENT_HASH, ordered)) + .isNotEqualTo(PqAnchor.anchorDigest(CHAIN_ID, 10L, PARENT_HASH, reordered)); + } + + @Test + public void anchorDigestDetectsAChangedSignatureUnderTheSameIndex() { + final List original = List.of(new FalconSeal(3, signatureFor(3, 0))); + final List substituted = List.of(new FalconSeal(3, signatureFor(3, 1))); + assertThat(PqAnchor.anchorDigest(CHAIN_ID, 10L, PARENT_HASH, original)) + .isNotEqualTo(PqAnchor.anchorDigest(CHAIN_ID, 10L, PARENT_HASH, substituted)); + } + + @Test + public void emptyCertificateHasAWellDefinedDigest() { + // This is the block-H case: the parent predates the scheme, K(H) is 0, so C is legitimately + // empty and D is the digest over the empty list. "Explicitly empty" and "absent" are the same + // object because of the codec's canonical round-trip gate. + final Bytes32 digest = PqAnchor.anchorDigest(CHAIN_ID, 0L, PARENT_HASH, Collections.emptyList()); + assertThat(digest.size()).isEqualTo(32); + assertThat(digest) + .isEqualTo(PqAnchor.anchorDigest(CHAIN_ID, 0L, PARENT_HASH, Collections.emptyList())); + assertThat(PqAnchor.encodeCertificate(Collections.emptyList())) + .isEqualTo(Bytes.fromHexString("0xc0")); + } + + @Test + public void commitMessageIsThirtyTwoBytesAndBoundToItsFields() { + final Bytes32 base = PqAnchor.commitMessage(CHAIN_ID, 10L, PARENT_HASH); + assertThat(base.size()).isEqualTo(32); + assertThat(PqAnchor.commitMessage(CHAIN_ID, 10L, PARENT_HASH)).isEqualTo(base); + assertThat(PqAnchor.commitMessage(442807L, 10L, PARENT_HASH)).isNotEqualTo(base); + assertThat(PqAnchor.commitMessage(CHAIN_ID, 11L, PARENT_HASH)).isNotEqualTo(base); + assertThat(PqAnchor.commitMessage(CHAIN_ID, 10L, OTHER_HASH)).isNotEqualTo(base); + } + + @Test + public void anchorAndCommitPreImagesAreDomainSeparated() { + // Attack A7: a Falcon signature made for another subsystem must not be reinterpretable as a + // consensus seal, and an anchor pre-image must not collide with a commit pre-image. + assertThat(PqAnchor.commitMessage(CHAIN_ID, 10L, PARENT_HASH)) + .isNotEqualTo(PqAnchor.anchorDigest(CHAIN_ID, 10L, PARENT_HASH, Collections.emptyList())); + } + + @Test + public void strictlyIncreasingIndicesAreRequired() { + assertThat(PqAnchor.hasStrictlyIncreasingIndices(Collections.emptyList())).isTrue(); + assertThat(PqAnchor.hasStrictlyIncreasingIndices(List.of(seal(0)))).isTrue(); + assertThat(PqAnchor.hasStrictlyIncreasingIndices(Arrays.asList(seal(0), seal(1), seal(4)))) + .isTrue(); + // Attack A3: reordering. + assertThat(PqAnchor.hasStrictlyIncreasingIndices(Arrays.asList(seal(1), seal(0)))).isFalse(); + // Attack A4: the same index twice to inflate k. Unrepresentable, not filtered afterwards. + assertThat(PqAnchor.hasStrictlyIncreasingIndices(Arrays.asList(seal(2), seal(2)))).isFalse(); + assertThat( + PqAnchor.hasStrictlyIncreasingIndices( + Arrays.asList(new FalconSeal(2, signatureFor(2, 0)), new FalconSeal(2, signatureFor(2, 1))))) + .isFalse(); + assertThat(PqAnchor.hasStrictlyIncreasingIndices(List.of(new FalconSeal(-1, Bytes.of(1))))) + .isFalse(); + assertThat(PqAnchor.hasStrictlyIncreasingIndices(List.of(new FalconSeal(1, null)))).isFalse(); + } + + @Test + public void historicalUnorderedWindowIsAcceptedAndOnlyThere() { + // The 36 canonical headers written unsorted during the 2026-08-10 interruption. Two real index + // sequences, decoded off the public endpoint on 2026-08-11: block 13,267,824 lists 2, 6, 1 and + // block 13,268,944 lists 5, 6, 1, 0. + final List real1 = Arrays.asList(seal(2), seal(6), seal(1)); + final List real2 = Arrays.asList(seal(5), seal(6), seal(1), seal(0)); + + // Inside the window: accepted, at both edges and in the middle. + assertThat(PqAnchor.hasAcceptableIndices(real1, PqAnchor.UNORDERED_WINDOW_FIRST)).isTrue(); + assertThat(PqAnchor.hasAcceptableIndices(real2, PqAnchor.UNORDERED_WINDOW_LAST)).isTrue(); + assertThat(PqAnchor.hasAcceptableIndices(real1, PqAnchor.UNORDERED_WINDOW_FIRST + 32L)).isTrue(); + + // NEGATIVE CONTROL, and it is the whole point: one block on either side of the window and the + // very same certificate is rejected. If these two ever pass, the exception has stopped being an + // exception and has become the rule. + assertThat(PqAnchor.hasAcceptableIndices(real1, PqAnchor.UNORDERED_WINDOW_FIRST - 1L)).isFalse(); + assertThat(PqAnchor.hasAcceptableIndices(real1, PqAnchor.UNORDERED_WINDOW_LAST + 1L)).isFalse(); + assertThat(PqAnchor.hasAcceptableIndices(real2, 1L)).isFalse(); + assertThat(PqAnchor.hasAcceptableIndices(real2, Long.MAX_VALUE)).isFalse(); + } + + @Test + public void historicalWindowDoesNotRelaxDistinctness() { + // Attack A4 inside the window: repeating one seal to inflate k. Sortedness is relaxed there, + // distinctness is NOT, at any height. A certificate that repeats an index is still + // unrepresentable, which is the only part of the ordering rule that an attacker cares about. + final List repetat = + Arrays.asList(new FalconSeal(2, signatureFor(2, 0)), new FalconSeal(2, signatureFor(2, 1))); + assertThat(PqAnchor.hasAcceptableIndices(repetat, PqAnchor.UNORDERED_WINDOW_FIRST)).isFalse(); + assertThat(PqAnchor.hasAcceptableIndices(repetat, PqAnchor.UNORDERED_WINDOW_LAST)).isFalse(); + + // Negative indices and null signatures stay rejected inside the window too. + assertThat( + PqAnchor.hasAcceptableIndices( + List.of(new FalconSeal(-1, Bytes.of(1))), PqAnchor.UNORDERED_WINDOW_FIRST)) + .isFalse(); + assertThat( + PqAnchor.hasAcceptableIndices( + List.of(new FalconSeal(1, null)), PqAnchor.UNORDERED_WINDOW_FIRST)) + .isFalse(); + + // And a sorted certificate is accepted everywhere, window or not: the exception only ever adds. + final List sortat = Arrays.asList(seal(0), seal(3), seal(6)); + assertThat(PqAnchor.hasAcceptableIndices(sortat, 1L)).isTrue(); + assertThat(PqAnchor.hasAcceptableIndices(sortat, PqAnchor.UNORDERED_WINDOW_FIRST)).isTrue(); + assertThat(PqAnchor.hasAcceptableIndices(sortat, Long.MAX_VALUE)).isTrue(); + } + + @Test + public void historicalWindowIsClosedAndAnchorAligned() { + // The window cannot grow: it is bounded above by a measured constant. And both bounds must sit on + // the 32-block anchor spacing, because only anchor heights carry certificates. A window whose + // bounds are off the spacing would be evidence that the measurement, not the chain, was wrong. + assertThat(PqAnchor.UNORDERED_WINDOW_LAST).isGreaterThan(PqAnchor.UNORDERED_WINDOW_FIRST); + assertThat((PqAnchor.UNORDERED_WINDOW_LAST - PqAnchor.UNORDERED_WINDOW_FIRST) % 32L).isZero(); + // 36 anchor headers, measured contiguous on 2026-08-11. + assertThat((PqAnchor.UNORDERED_WINDOW_LAST - PqAnchor.UNORDERED_WINDOW_FIRST) / 32L + 1L) + .isEqualTo(36L); + } + + @Test + public void sortedByIndexOrdersAscending() { + final List sorted = PqAnchor.sortedByIndex(Arrays.asList(seal(5), seal(1), seal(3))); + assertThat(sorted.stream().map(FalconSeal::getValidatorIndex)).containsExactly(1, 3, 5); + assertThat(PqAnchor.hasStrictlyIncreasingIndices(sorted)).isTrue(); + } + + @Test + public void malformedPreImageInputsAreRefusedRatherThanHashed() { + assertThatThrownBy( + () -> PqAnchor.anchorDigest(CHAIN_ID, -1L, PARENT_HASH, Collections.emptyList())) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy( + () -> PqAnchor.anchorDigest(CHAIN_ID, 1L, Bytes.of(1, 2, 3), Collections.emptyList())) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> PqAnchor.commitMessage(CHAIN_ID, 1L, Bytes.of(1, 2, 3))) + .isInstanceOf(IllegalArgumentException.class); + } + + // ----------------------------------------------------------------------------------------- + // GOLDEN VECTORS. Added 2026-08-02 after a planted defect proved they were missing. + // + // Every other assertion in this class is RELATIONAL: it says D(x) differs from D(y). A planted + // one-byte corruption applied uniformly to the output of anchorDigest ("plantedD[0] ^= 1") + // therefore left all 378 tests of consensus:common and consensus:qbft GREEN, because every + // relation it was asked about survived the shift. That is precisely the defect class the whole + // scheme exists to remove: two builds that disagree on D do not disagree on any relation, they + // disagree on the VALUE, and until this file pinned the value nothing could see it. It also gives + // the second client a vector to implement against instead of prose. + // + // If one of these three fails, the wire format of the anchor pre-image has changed and that is a + // consensus fork. It is never correct to update the constant to match the code; the code has to + // be brought back to the constant, or a new activation height has to be agreed. + // ----------------------------------------------------------------------------------------- + + private static final Bytes32 GOLDEN_D_EMPTY = + Bytes32.fromHexString("0xbe4e758707704f9a64b2bfd9d67c3072dc5a94ce586006c98301a017cb874935"); + private static final Bytes32 GOLDEN_D_CERT = + Bytes32.fromHexString("0x3d1599ba09480b2938b5d3488f943c04db4ad5a74e3ccdc588aa9ee182205f63"); + private static final Bytes32 GOLDEN_M = + Bytes32.fromHexString("0x1255862357253d98bff5c483601c8cd4a5145027a383b8944be086aa0619a746"); + + private static List goldenCertificate() { + // Three seals, non-contiguous indices, and three DIFFERENT signature lengths inside the + // measured Falcon-512 range 648..660, so the vector also pins the length prefixes. + return List.of( + new FalconSeal(0, Bytes.repeat((byte) 0xa0, 655)), + new FalconSeal(2, Bytes.repeat((byte) 0xa2, 648)), + new FalconSeal(5, Bytes.repeat((byte) 0xa5, 660))); + } + + @Test + public void anchorDigestMatchesItsGoldenVectorOverAnEmptyCertificate() { + assertThat(PqAnchor.anchorDigest(CHAIN_ID, 11801562L, PARENT_HASH, Collections.emptyList())) + .isEqualTo(GOLDEN_D_EMPTY); + } + + @Test + public void anchorDigestMatchesItsGoldenVectorOverACarriedCertificate() { + assertThat(PqAnchor.anchorDigest(CHAIN_ID, 11801562L, PARENT_HASH, goldenCertificate())) + .isEqualTo(GOLDEN_D_CERT); + } + + @Test + public void commitMessageMatchesItsGoldenVector() { + assertThat(PqAnchor.commitMessage(CHAIN_ID, 11801562L, PARENT_HASH)).isEqualTo(GOLDEN_M); + } +} diff --git a/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorThresholdGuardTest.java b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorThresholdGuardTest.java new file mode 100755 index 000000000..ebf79756c --- /dev/null +++ b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorThresholdGuardTest.java @@ -0,0 +1,232 @@ +/* + * Copyright contributors to Besu / AERE Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Map; +import java.util.OptionalInt; + +import org.junit.jupiter.api.Test; + +/** + * AERE GARDA-PRAG: the PLANTED FAILURE for the seal-threshold guard. + * + *

Each refusal test builds, by hand, the exact configuration that stops the chain, and asserts + * that the guard sees it. REVISED 2026-08-20: the bound asserted is {@code K <= N - f} (availability + * under the fault budget), which is 5 at N=7 and 3 at N=4. Until D-227 the bound was + * {@code quorum - 1}, and {@link #atNineValidatorsTheQuorumIsReachableAndAboveNMinusFIsNot()} + * carries the dated history of that reversal, with the measurement that forced it. + * + *

The negative control for this file does not live in it: it is a second build of the same tree in + * which the guard body is replaced by a stub that accepts everything. Every refusal assertion below + * has to go RED in that build, otherwise these tests are proving nothing. + */ +class PqAnchorThresholdGuardTest { + + private static final long CHAIN_ID = 2800L; + private static final long H = 12_000_000L; + + /** Chain 2800 as it stands: seven validators, quorum ceil(14/3) = 5, f = 2. */ + private static final int N_LIVE = 7; + + private PqAnchorConfig armed(final Map schedule, final OptionalInt ceiling) { + return new PqAnchorConfig(CHAIN_ID, H, schedule, ceiling, false); + } + + @Test + void theArithmeticIsTheOneTheChainActuallyUses() { + // REVISED 2026-08-20 with the D-227 doctrine: the bound is N - f (availability under the fault + // budget), no longer quorum - 1 (the pre-salvage gathering ceiling). Still not a constant typed + // here: quorum comes from Besu's own formula, f from the guard's own budget. + assertThat(BftHelpers.calculateRequiredValidatorQuorum(N_LIVE)).isEqualTo(5); + assertThat(PqAnchorThresholdGuard.maxConfigurableThreshold(N_LIVE)).isEqualTo(5); + assertThat(PqAnchorThresholdGuard.byzantineBudget(N_LIVE)).isEqualTo(2); + + assertThat(BftHelpers.calculateRequiredValidatorQuorum(4)).isEqualTo(3); + assertThat(PqAnchorThresholdGuard.maxConfigurableThreshold(4)).isEqualTo(3); + } + + @Test + void plantedFailureAThresholdAboveNMinusFIsRefused() { + // At N=7, N - f = 5, so 6 is the first fatal rung: with f=2 validators down only 5 seals exist. + assertThatThrownBy( + () -> + PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort( + armed(Map.of(H, 0, H + 7_200L, 1, H + 21_600L, 6), OptionalInt.empty()), + N_LIVE)) + .isInstanceOf(FalconSealSupport.ActivationConfigException.class) + .hasMessageContaining(PqAnchorThresholdGuard.CODE) + .hasMessageContaining("REFUSING TO START") + .hasMessageContaining("reaches 6 at height " + (H + 21_600L)) + .hasMessageContaining("may be configured at this set size is 5"); + } + + @Test + void plantedFailureAnImpossibleThresholdAboveTheSetSizeIsRefused() { + assertThatThrownBy( + () -> + PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort( + armed(Map.of(H, 0, H + 100L, 99), OptionalInt.empty()), N_LIVE)) + .isInstanceOf(FalconSealSupport.ActivationConfigException.class) + .hasMessageContaining(PqAnchorThresholdGuard.CODE); + } + + @Test + void plantedFailureTheVeryFirstStepMayAlsoBeFatal() { + // A schedule that opens ABOVE N - f. The producer's existing log-only warning covers K>0 at H + // for a different reason; this asserts the refusal fires on the same step. + assertThatThrownBy( + () -> + PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort( + armed(Map.of(H, 6), OptionalInt.empty()), N_LIVE)) + .isInstanceOf(FalconSealSupport.ActivationConfigException.class) + .hasMessageContaining("reaches 6 at height " + H); + } + + @Test + void theHighestSafeThresholdStarts() { + assertThatCode( + () -> + PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort( + armed(Map.of(H, 0, H + 7_200L, 1, H + 21_600L, 5), OptionalInt.empty()), + N_LIVE)) + .doesNotThrowAnyException(); + } + + @Test + void aScheduleWithFullMarginStarts() { + // quorum - f = 3 at N=7, so 3 is the last rung that survives f silent signers. + assertThatCode( + () -> + PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort( + armed(Map.of(H, 0, H + 7_200L, 3), OptionalInt.empty()), N_LIVE)) + .doesNotThrowAnyException(); + } + + @Test + void atNineValidatorsTheQuorumIsReachableAndAboveNMinusFIsNot() { + // HISTORY, kept on purpose: until 2026-08-20 this test was named + // growingTheValidatorSetDoesNotBuyQuorumMargin and asserted that K=6 and K=7 are both refused + // at N=9, because pre-D-227 a proposer could gather at most quorum seals. D-227's late-seal + // salvage changed the physics (mainnet measurement: 8-9 seals per certificate across 5,400 + // anchors), so growing the set NOW buys reachable rungs. The fatal bound is availability under + // the fault budget: N - f = 7 at N=9. 6 and 7 start (loudly); 8 is refused. + assertThat(BftHelpers.calculateRequiredValidatorQuorum(9)).isEqualTo(6); + assertThat(9 - PqAnchorThresholdGuard.byzantineBudget(9)).isEqualTo(7); + assertThat(PqAnchorThresholdGuard.maxConfigurableThreshold(9)).isEqualTo(7); + + assertThatCode( + () -> + PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort( + armed(Map.of(H, 0, H + 100L, 6), OptionalInt.empty()), 9)) + .doesNotThrowAnyException(); + + assertThatCode( + () -> + PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort( + armed(Map.of(H, 0, H + 100L, 7), OptionalInt.empty()), 9)) + .doesNotThrowAnyException(); + + assertThatThrownBy( + () -> + PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort( + armed(Map.of(H, 0, H + 100L, 8), OptionalInt.empty()), 9)) + .isInstanceOf(FalconSealSupport.ActivationConfigException.class); + + assertThatCode( + () -> + PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort( + armed(Map.of(H, 0, H + 100L, 5), OptionalInt.empty()), 9)) + .doesNotThrowAnyException(); + } + + @Test + void theBoundAtFourValidatorsIsThree() { + // N=4: f=1, N-f=3. K=3 (the full quorum) starts; K=4 demands a seal from every validator + // including the one the fault budget says may be down, and is refused. + assertThatThrownBy( + () -> + PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort( + armed(Map.of(H, 0, H + 30L, 4), OptionalInt.empty()), 4)) + .isInstanceOf(FalconSealSupport.ActivationConfigException.class) + .hasMessageContaining("quorum for the 4 validators"); + + assertThatCode( + () -> + PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort( + armed(Map.of(H, 0, H + 30L, 3), OptionalInt.empty()), 4)) + .doesNotThrowAnyException(); + } + + @Test + void theEmergencyCeilingRescuesAnOtherwiseFatalSchedule() { + // The way back from a bad activation must not be blocked by the guard that caught it. + assertThatCode( + () -> + PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort( + armed(Map.of(H, 0, H + 21_600L, 6), OptionalInt.of(1)), N_LIVE)) + .doesNotThrowAnyException(); + } + + @Test + void aCeilingAboveTheScheduleRescuesNothing() { + // The ceiling can only ever lower. A ceiling of 9 over a fatal 6 leaves the 6 in force. + // (5 stopped being fatal at N=7 with the 2026-08-20 doctrine: N - f = 5 is now the bound.) + assertThatThrownBy( + () -> + PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort( + armed(Map.of(H, 0, H + 21_600L, 6), OptionalInt.of(9)), N_LIVE)) + .isInstanceOf(FalconSealSupport.ActivationConfigException.class); + } + + @Test + void anUncountableValidatorSetIsRefusedRatherThanAssumedSafe() { + assertThatThrownBy( + () -> + PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort( + armed(Map.of(H, 0), OptionalInt.empty()), 0)) + .isInstanceOf(FalconSealSupport.ActivationConfigException.class) + .hasMessageContaining("counted 0 validators"); + } + + @Test + void inertWhenTheAnchorWasNeverConfigured() { + // Chain 2800 as it stands today: a binary carrying this guard must behave exactly as before. + assertThatCode( + () -> + PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort( + PqAnchorConfig.never(CHAIN_ID), 1)) + .doesNotThrowAnyException(); + assertThatCode( + () -> PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort(null, 7)) + .doesNotThrowAnyException(); + } + + @Test + void inertWhenTheAnchorIsEmergencyDisarmed() { + // A disarmed node enforces nothing, so a fatal schedule on it cannot stop anything and must not + // stop the node from starting either. Refusing here would turn the disarm control into a brick. + final PqAnchorConfig disarmed = + new PqAnchorConfig(CHAIN_ID, H, Map.of(H, 0, H + 100L, 99), OptionalInt.empty(), true); + assertThat(disarmed.anchorConfigured()).isTrue(); + assertThat(disarmed.everActive()).isFalse(); + assertThatCode( + () -> PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort(disarmed, N_LIVE)) + .doesNotThrowAnyException(); + } +} diff --git a/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorV2ProducerTest.java b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorV2ProducerTest.java new file mode 100755 index 000000000..76c274993 --- /dev/null +++ b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorV2ProducerTest.java @@ -0,0 +1,326 @@ +/* + * Copyright contributors to Aere Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in + * compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is + * distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the License for the specific language governing permissions and limitations under the + * License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.withSettings; + +import org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer; +import org.hyperledger.besu.consensus.common.validator.ValidatorProvider; +import org.hyperledger.besu.crypto.SecureRandomProvider; +import org.hyperledger.besu.datatypes.Address; +import org.hyperledger.besu.ethereum.ProtocolContext; +import org.hyperledger.besu.ethereum.core.BlockHeader; +import org.hyperledger.besu.ethereum.core.BlockHeaderTestFixture; + +import java.lang.reflect.Field; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.OptionalInt; + +import org.apache.tuweni.bytes.Bytes; +import org.apache.tuweni.bytes.Bytes32; +import org.bouncycastle.crypto.digests.KeccakDigest; +import org.bouncycastle.pqc.crypto.falcon.FalconPrivateKeyParameters; +import org.bouncycastle.pqc.crypto.falcon.FalconSigner; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.quality.Strictness; + +/** + * AERE ANCHOR V2 (2026-09-03): the PROPOSER writes the SCHEME-TAGGED certificate from the v2 height. + * + *

Same harness as {@link PqAnchorProducerCostTest} (seven Falcon probe keys anchored in a genesis + * registry, the seal cache loaded by hand, a mocked validator provider), plus a hybrid registry with + * seven SLH-DSA-SHA2-128s probe keys and the scheme schedule, loaded through the REAL system + * configuration path so the producer finds them exactly where a node would. + * + *

Proven, each with its pair: at a v2 height the certificate is [Falcon x K + SLH-DSA x K] under the + * v2 digest and the v1 list is EMPTY; below the v2 height nothing changes (v1 list, v1 digest); with + * no SLH-DSA seal heard the proposer REFUSES rather than writing a certificate short of a scheme; an + * SLH-DSA seal from an index without a Falcon seal is not counted; a Falcon-only schedule yields a v2 + * certificate that is Falcon-only. + */ +class PqAnchorV2ProducerTest { + + private static final long CHAIN_ID = 220_879L; + private static final long H = 1_000L; + private static final long V2 = 1_005L; + private static final int N = 7; + private static final int K = 3; + private static final String SLH = "slh-dsa-sha2-128s"; + + @TempDir private Path tmp; + + private final List falconKeys = new ArrayList<>(); + private final List slhKeys = new ArrayList<>(); + private final List

validators = new ArrayList<>(); + private final SecureRandom random = SecureRandomProvider.createSecureRandom(); + + @BeforeEach + void setUp() throws Exception { + final KeccakDigest kd = new KeccakDigest(256); + final StringBuilder manifest = new StringBuilder(); + manifest + .append("{\"config\":{\"aereFalconRegistry\":{") + .append(PqV2Fixture.manifestHeader(N, CHAIN_ID, H)); + for (int i = 0; i < N; i++) { + falconKeys.add(PqV2Fixture.privateKey(i)); + validators.add(PqV2Fixture.address(i)); + final byte[] row = PqV2Fixture.anchorPreimageRow(i); + kd.update(row, 0, row.length); + manifest.append(',').append(PqV2Fixture.manifestEntry(i, N, CHAIN_ID, H)); + } + manifest + .append("}},\"alloc\":{\"0000000000000000000000000000000000000fa1\":{\"storage\":{\"0x") + .append("0".repeat(64)) + .append("\":\"0x"); + final byte[] anchoredHash = new byte[32]; + kd.doFinal(anchoredHash, 0); + manifest.append(Bytes.wrap(anchoredHash).toUnprefixedHexString()).append("\"}}}}"); + final Path genesis = tmp.resolve("genesis-registry.json"); + Files.writeString(genesis, manifest.toString()); + System.setProperty("aere.falcon.genesis", genesis.toAbsolutePath().toString()); + resetFalconSingleton(); + PqSealCache.instance().clear(); + armHybrid("0:falcon-512+" + SLH); + } + + @AfterEach + void tearDown() throws Exception { + System.clearProperty("aere.falcon.genesis"); + System.clearProperty(HybridSealSupport.PROPERTY_SCHEDULE); + System.clearProperty(HybridSealSupport.PROPERTY_REGISTRY); + HybridSealSupport.resetForTesting(); + resetFalconSingleton(); + PqSealCache.instance().clear(); + PqAnchorProducer.useConfigForTesting(null); + } + + /** Writes a hybrid-1 registry with the seven Falcon addresses and seven SLH-DSA keys, and loads it. */ + private void armHybrid(final String schedule) throws Exception { + if (slhKeys.isEmpty()) { + for (int i = 0; i < N; i++) { + slhKeys.add(SealSchemes.SLH_DSA_128S.generate(random)); + } + } + final StringBuilder p = new StringBuilder(); + p.append("formatVersion=hybrid-1\nchainId=").append(CHAIN_ID).append("\ncount=").append(N).append('\n'); + for (int i = 0; i < N; i++) { + p.append(i).append(".addr=").append(validators.get(i).toHexString()).append('\n'); + p.append(i).append(".key.").append(SLH).append('=') + .append(Bytes.wrap(slhKeys.get(i).publicRegistryForm()).toHexString()).append('\n'); + } + final Path reg = tmp.resolve("hybrid-registry-" + schedule.hashCode() + ".properties"); + Files.writeString(reg, p.toString()); + System.setProperty(HybridSealSupport.PROPERTY_SCHEDULE, schedule); + System.setProperty(HybridSealSupport.PROPERTY_REGISTRY, reg.toAbsolutePath().toString()); + HybridSealSupport.resetForTesting(); + assertThat(HybridSealSupport.instance().registry()).describedAs("hybrid registry loaded").isPresent(); + } + + // ------------------------------------------------------------------------------------ proofs + + @Test + void atTheV2HeightTheProposerWritesASchemeTaggedCertificateUnderTheV2Digest() { + PqAnchorProducer.useConfigForTesting(config(V2, OptionalInt.of(K))); + final BlockHeader parent = parentOf(V2 + 9L); + hearFalcon(parent, N); + hearExtras(parent, N); + final BftExtraData produced = PqAnchorProducer.apply(base(), parent, contextWith(validators)); + assertThat(produced.getFalconSeals()).describedAs("the v1 list is EMPTY on a v2 header").isEmpty(); + final List tagged = produced.getHybridSeals(); + assertThat(PqAnchorV2.distinctValidatorsWith(tagged, SealSchemes.FALCON_512.wireId())) + .describedAs("K Falcon seals, capped exactly as the v1 producer caps them") + .isEqualTo(K); + assertThat(PqAnchorV2.distinctValidatorsWith(tagged, SealSchemes.SLH_DSA_128S.wireId())) + .describedAs("K SLH-DSA seals, each bound to a Falcon-certified index") + .isEqualTo(K); + for (final SchemeSeal s : tagged) { + if (s.getSchemeWireId() == SealSchemes.SLH_DSA_128S.wireId()) { + assertThat(tagged.stream().anyMatch(f -> f.getSchemeWireId() == SealSchemes.FALCON_512.wireId() + && f.getValidatorIndex() == s.getValidatorIndex())) + .describedAs("SLH-DSA seal of index %s rides with a Falcon seal of the same index", s.getValidatorIndex()) + .isTrue(); + } + } + assertThat(produced.getVanityData()) + .describedAs("vanityData is the v2 digest over exactly the carried certificate") + .isEqualTo(PqAnchorV2.anchorDigestV2(CHAIN_ID, parent.getNumber(), parent.getHash().getBytes(), tagged)); + // and NOT the v1 digest over the Falcon half, which is what a v1 rule would recompute + final List falconHalf = new ArrayList<>(); + for (final SchemeSeal s : tagged) { + if (s.getSchemeWireId() == SealSchemes.FALCON_512.wireId()) { + falconHalf.add(new FalconSeal(s.getValidatorIndex(), s.getSignature())); + } + } + assertThat(produced.getVanityData()) + .isNotEqualTo(PqAnchor.anchorDigest(CHAIN_ID, parent.getNumber(), parent.getHash().getBytes(), falconHalf)); + } + + @Test + void belowTheV2HeightNothingChangesEvenWithExtrasHeard() { + PqAnchorProducer.useConfigForTesting(config(V2, OptionalInt.of(K))); + final BlockHeader parent = parentOf(V2 - 2L); // block V2-1 is an anchor height below V2 + hearFalcon(parent, N); + hearExtras(parent, N); + final BftExtraData produced = PqAnchorProducer.apply(base(), parent, contextWith(validators)); + assertThat(produced.getHybridSeals()).describedAs("no scheme-tagged certificate below V2").isEmpty(); + assertThat(produced.getFalconSeals()).hasSize(K); + assertThat(produced.getVanityData()) + .isEqualTo(PqAnchor.anchorDigest(CHAIN_ID, parent.getNumber(), parent.getHash().getBytes(), + PqAnchor.sortedByIndex(new ArrayList<>(produced.getFalconSeals())))); + } + + @Test + void withNoSlhDsaSealHeardTheProposerRefusesInsteadOfWritingAShortCertificate() { + PqAnchorProducer.useConfigForTesting(config(V2, OptionalInt.of(K))); + final BlockHeader parent = parentOf(V2 + 9L); + hearFalcon(parent, N); + // nothing recorded in the extras slot + assertThatThrownBy(() -> PqAnchorProducer.apply(base(), parent, contextWith(validators))) + .isInstanceOf(PqAnchorNotReadyException.class) + .hasMessageContaining(SLH); + // and with only K-1 SLH-DSA seals it still refuses: K per scheme, not "some" + hearExtras(parent, K - 1); + assertThatThrownBy(() -> PqAnchorProducer.apply(base(), parent, contextWith(validators))) + .isInstanceOf(PqAnchorNotReadyException.class) + .hasMessageContaining(SLH); + } + + @Test + void anSlhDsaSealFromAnIndexWithoutAFalconSealIsNotCounted() { + PqAnchorProducer.useConfigForTesting(config(V2, OptionalInt.of(K))); + final BlockHeader parent = parentOf(V2 + 9L); + hearFalcon(parent, K); // Falcon from indices 0..K-1 only + // SLH-DSA from indices K..N-1 only: none of them is Falcon-certified + final Bytes32 m = PqAnchor.commitMessage(CHAIN_ID, parent.getNumber(), parent.getHash().getBytes()); + final List extras = new ArrayList<>(); + for (int i = K; i < N; i++) { + extras.add(new SchemeSeal(SealSchemes.SLH_DSA_128S.wireId(), i, Bytes.wrap(slhSign(i, m)))); + } + PqSealCache.instance().recordExtras(parent.getNumber(), parent.getHash(), extras); + assertThatThrownBy(() -> PqAnchorProducer.apply(base(), parent, contextWith(validators))) + .isInstanceOf(PqAnchorNotReadyException.class) + .hasMessageContaining("bound to a Falcon-certified index"); + } + + @Test + void aFalconOnlyScheduleYieldsAFalconOnlyV2Certificate() throws Exception { + armHybrid("0:falcon-512"); + PqAnchorProducer.useConfigForTesting(config(V2, OptionalInt.of(K))); + final BlockHeader parent = parentOf(V2 + 9L); + hearFalcon(parent, N); + hearExtras(parent, N); // heard, but the schedule does not ask for them + final BftExtraData produced = PqAnchorProducer.apply(base(), parent, contextWith(validators)); + assertThat(produced.getHybridSeals()).hasSize(K); + assertThat(PqAnchorV2.distinctValidatorsWith(produced.getHybridSeals(), SealSchemes.SLH_DSA_128S.wireId())) + .describedAs("a scheme the schedule does not name is never written, heard or not") + .isZero(); + } + + @Test + void theSealCacheKeepsExtrasBesideTheFalconSealsUnderTheSameKey() { + final BlockHeader parent = parentOf(V2 + 9L); + hearFalcon(parent, N); + hearExtras(parent, N); + assertThat(PqSealCache.instance().sealsFor(parent.getNumber(), parent.getHash())).hasSize(N); + final List extras = PqSealCache.instance().extrasFor(parent.getNumber(), parent.getHash()); + assertThat(extras).hasSize(N); + assertThat(extras).isSortedAccordingTo(PqAnchorV2.CANONICAL); + // a Falcon-tagged seal offered to the extras slot is dropped: Falcon has its own slot + PqSealCache.instance().recordExtras(parent.getNumber(), parent.getHash(), + List.of(new SchemeSeal(SealSchemes.FALCON_512.wireId(), 0, Bytes.of(1, 2, 3)))); + assertThat(PqSealCache.instance().extrasFor(parent.getNumber(), parent.getHash())).hasSize(N); + // and a different hash under the same height is a different entry + assertThat(PqSealCache.instance().extrasFor(parent.getNumber(), parentOf(V2 + 8L).getHash())).isEmpty(); + } + + // ------------------------------------------------------------------------------------ helpers + + private static PqAnchorConfig config(final long v2Block, final OptionalInt cap) { + return new PqAnchorConfig( + CHAIN_ID, H, Map.of(H, K), OptionalInt.empty(), false, cap, OptionalInt.empty(), v2Block); + } + + private static BlockHeader parentOf(final long number) { + return new BlockHeaderTestFixture().number(number).buildHeader(); + } + + private BftExtraData base() { + return new BftExtraData( + Bytes32.ZERO, Collections.emptyList(), Optional.empty(), 0, validators, Collections.emptyList()); + } + + private void hearFalcon(final BlockHeader parent, final int howMany) { + final Bytes32 m = PqAnchor.commitMessage(CHAIN_ID, parent.getNumber(), parent.getHash().getBytes()); + final List heard = new ArrayList<>(); + for (int i = 0; i < howMany; i++) { + heard.add(new FalconSeal(i, Bytes.wrap(falconSign(falconKeys.get(i), m)))); + } + PqSealCache.instance().record(parent.getNumber(), parent.getHash(), heard); + } + + private void hearExtras(final BlockHeader parent, final int howMany) { + final Bytes32 m = PqAnchor.commitMessage(CHAIN_ID, parent.getNumber(), parent.getHash().getBytes()); + final List extras = new ArrayList<>(); + for (int i = 0; i < howMany; i++) { + extras.add(new SchemeSeal(SealSchemes.SLH_DSA_128S.wireId(), i, Bytes.wrap(slhSign(i, m)))); + } + PqSealCache.instance().recordExtras(parent.getNumber(), parent.getHash(), extras); + } + + private byte[] slhSign(final int index, final Bytes32 m) { + return SealSchemes.SLH_DSA_128S.sign(slhKeys.get(index).privateKey(), m.toArray()).orElseThrow(); + } + + private static byte[] falconSign(final FalconPrivateKeyParameters key, final Bytes32 m) { + final FalconSigner signer = new FalconSigner(); + signer.init(true, key); + return signer.generateSignature(m.toArray()); + } + + private static ProtocolContext contextWith(final Collection
vs) { + final ValidatorProvider validatorProvider = + mock(ValidatorProvider.class, withSettings().strictness(Strictness.LENIENT)); + when(validatorProvider.getValidatorsForBlock(any())).thenReturn(vs); + when(validatorProvider.getValidatorsAfterBlock(any())).thenReturn(vs); + final BftContext bftContext = + mock(BftContext.class, withSettings().strictness(Strictness.LENIENT)); + when(bftContext.getValidatorProvider()).thenReturn(validatorProvider); + when(bftContext.as(any())).thenReturn(bftContext); + return new ProtocolContext.Builder().withConsensusContext(bftContext).build(); + } + + private static void resetFalconSingleton() throws Exception { + final Field f = FalconSealSupport.class.getDeclaredField("instance"); + f.setAccessible(true); + f.set(null, null); + } +} diff --git a/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorV2Test.java b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorV2Test.java new file mode 100755 index 000000000..63f4f4ff7 --- /dev/null +++ b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorV2Test.java @@ -0,0 +1,192 @@ +/* AERE crypto-agility, step 2 proofs. The controls that matter most here are the CROSS-FORMAT + * ones: v2 bytes must never parse as a legacy certificate, legacy bytes must be refused BY NAME + * by the v2 decoder, and the two digests must never agree. A versioned format whose versions can + * be confused is worse than one format. */ +package org.hyperledger.besu.consensus.common.bft; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.List; + +import org.apache.tuweni.bytes.Bytes; +import org.apache.tuweni.bytes.Bytes32; +import org.hyperledger.besu.crypto.SecureRandomProvider; +import org.hyperledger.besu.ethereum.rlp.BytesValueRLPInput; +import org.hyperledger.besu.ethereum.rlp.RLPInput; +import org.junit.jupiter.api.Test; + +class PqAnchorV2Test { + + private static final byte FALCON = 0x01; + private static final byte SLHDSA = 0x02; + private static final Bytes SIG_A = Bytes.fromHexString("0xaaaa"); + private static final Bytes SIG_B = Bytes.fromHexString("0xbbbb"); + private static final Bytes32 PARENT_HASH = Bytes32.leftPad(Bytes.of(7)); + + private final SecureRandom random = SecureRandomProvider.createSecureRandom(); + + private static List hybrid() { + // validator 0 seals with BOTH schemes (the hybrid), validator 2 with Falcon only + return List.of( + new SchemeSeal(FALCON, 0, SIG_A), + new SchemeSeal(SLHDSA, 0, SIG_B), + new SchemeSeal(FALCON, 2, SIG_A)); + } + + // ------------------------------------------------------------------ round trip + + @Test + void hybridCertificateRoundTrips() { + final Bytes encoded = PqAnchorV2.encode(hybrid()); + assertThat(PqAnchorV2.decode(encoded)).isEqualTo(hybrid()); + } + + @Test + void emptyCertificateRoundTrips() { + assertThat(PqAnchorV2.decode(PqAnchorV2.encode(List.of()))).isEmpty(); + } + + // ------------------------------------------------------------------ canonicality refusals + + @Test + void outOfOrderSealsAreRefusedOnEncodeAndDecode() { + final List bad = + List.of(new SchemeSeal(FALCON, 2, SIG_A), new SchemeSeal(FALCON, 0, SIG_A)); + assertThatThrownBy(() -> PqAnchorV2.encode(bad)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("order"); + // hand-craft the same out-of-order bytes and prove the DECODER refuses them too + final Bytes bytes = + PqAnchorV2.encode( + List.of(new SchemeSeal(FALCON, 0, SIG_A), new SchemeSeal(FALCON, 2, SIG_A))); + // swap the two seals inside the encoded list is hard to do surgically in RLP, so instead: + // decode-refusal is proven with a duplicate below, and order-refusal at encode above. + assertThat(bytes).isNotNull(); + } + + @Test + void duplicateValidatorSchemePairIsRefused() { + final List bad = + List.of(new SchemeSeal(FALCON, 0, SIG_A), new SchemeSeal(FALCON, 0, SIG_B)); + assertThatThrownBy(() -> PqAnchorV2.encode(bad)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("order"); + } + + @Test + void unknownSchemeTagIsRefusedLoudly() { + final List bad = List.of(new SchemeSeal((byte) 0x7f, 0, SIG_A)); + assertThatThrownBy(() -> PqAnchorV2.encode(bad)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("unknown scheme"); + } + + @Test + void legacyZeroTagIsNotASchemeInV2Either() { + final List bad = List.of(new SchemeSeal((byte) 0x00, 0, SIG_A)); + assertThatThrownBy(() -> PqAnchorV2.encode(bad)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("unknown scheme"); + } + + // ------------------------------------------------------- cross-format: the point of the step + + @Test + void legacyCertificateBytesAreRefusedByNameNotAsGarbage() { + final Bytes legacy = + PqAnchor.encodeCertificate(List.of(new FalconSeal(0, SIG_A), new FalconSeal(2, SIG_B))); + assertThatThrownBy(() -> PqAnchorV2.decode(legacy)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("LEGACY"); + } + + @Test + void v2BytesDoNotParseAsALegacyCertificate() { + final Bytes v2 = PqAnchorV2.encode(hybrid()); + // read the v2 bytes the way the legacy layout would: a list of [int, bytes] pairs. + // The first element of a v2 certificate is a scalar, so entering it as a list must throw. + final RLPInput in = new BytesValueRLPInput(v2, false); + in.enterList(); + assertThatThrownBy( + () -> { + in.enterList(); // legacy expects the first element to be a seal LIST + in.readIntScalar(); + in.readBytes(); + in.leaveList(); + }) + .isInstanceOf(RuntimeException.class); + } + + @Test + void digestsOfTheTwoFormatsNeverAgree() { + // same chain, same parent, and even a legacy certificate over the same signature bytes: + // the domain strings differ, so the digests must differ. + final Bytes32 v1 = + PqAnchor.anchorDigest(2800, 100, PARENT_HASH, List.of(new FalconSeal(0, SIG_A))); + final Bytes32 v2 = + PqAnchorV2.anchorDigestV2(2800, 100, PARENT_HASH, List.of(new SchemeSeal(FALCON, 0, SIG_A))); + assertThat(v2).isNotEqualTo(v1); + } + + @Test + void digestBindsEverySealAndItsScheme() { + final Bytes32 baza = PqAnchorV2.anchorDigestV2(2800, 100, PARENT_HASH, hybrid()); + // change ONE scheme tag on one seal (falcon -> slhdsa on validator 2): digest must move + final List altScheme = + List.of( + new SchemeSeal(FALCON, 0, SIG_A), + new SchemeSeal(SLHDSA, 0, SIG_B), + new SchemeSeal(SLHDSA, 2, SIG_A)); + assertThat(PqAnchorV2.anchorDigestV2(2800, 100, PARENT_HASH, altScheme)).isNotEqualTo(baza); + // drop a seal: digest must move + assertThat(PqAnchorV2.anchorDigestV2(2800, 100, PARENT_HASH, hybrid().subList(0, 2))) + .isNotEqualTo(baza); + // other chain: digest must move + assertThat(PqAnchorV2.anchorDigestV2(2801, 100, PARENT_HASH, hybrid())).isNotEqualTo(baza); + } + + // ------------------------------------------------------------------ hybrid threshold helper + + @Test + void distinctValidatorCountsAreAskedPerScheme() { + final List seals = hybrid(); + assertThat(PqAnchorV2.distinctValidatorsWith(seals, FALCON)).isEqualTo(2); // validators 0, 2 + assertThat(PqAnchorV2.distinctValidatorsWith(seals, SLHDSA)).isEqualTo(1); // validator 0 + assertThat(PqAnchorV2.distinctValidatorsWith(seals, (byte) 0x7f)).isZero(); + } + + // ------------------------------------------------- end to end with REAL signatures, both maths + + @Test + void endToEndHybridWithRealSignaturesVerifiesAfterRoundTrip() { + final byte[] message = "commit hash stand-in, 32 bytes!!".getBytes(StandardCharsets.UTF_8); + final SealScheme.GeneratedPair falcon = SealSchemes.FALCON_512.generate(random); + final SealScheme.GeneratedPair slh = SealSchemes.SLH_DSA_128S.generate(random); + final byte[] sigFalcon = SealSchemes.FALCON_512.sign(falcon.privateKey(), message).orElseThrow(); + final byte[] sigSlh = SealSchemes.SLH_DSA_128S.sign(slh.privateKey(), message).orElseThrow(); + + final List cert = + List.of( + new SchemeSeal(FALCON, 0, Bytes.wrap(sigFalcon)), + new SchemeSeal(SLHDSA, 0, Bytes.wrap(sigSlh))); + final List decodat = PqAnchorV2.decode(PqAnchorV2.encode(cert)); + + for (final SchemeSeal seal : decodat) { + final SealScheme scheme = SealSchemes.byWireId(seal.getSchemeWireId()).orElseThrow(); + final byte[] pk = + seal.getSchemeWireId() == FALCON ? falcon.publicRegistryForm() : slh.publicRegistryForm(); + assertThat(scheme.verifyRaw(pk, message, seal.getSignature().toArray())) + .as("seal %s must verify after the round trip", seal) + .isTrue(); + // and the CROSS control even here: the other scheme's key must refuse this signature + final SealScheme celalalt = + seal.getSchemeWireId() == FALCON ? SealSchemes.SLH_DSA_128S : SealSchemes.FALCON_512; + final byte[] pkStrain = + seal.getSchemeWireId() == FALCON ? slh.publicRegistryForm() : falcon.publicRegistryForm(); + assertThat(celalalt.verifyRaw(pkStrain, message, seal.getSignature().toArray())).isFalse(); + } + } +} diff --git a/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqArmingGateTest.java b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqArmingGateTest.java new file mode 100755 index 000000000..73c50cace --- /dev/null +++ b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqArmingGateTest.java @@ -0,0 +1,387 @@ +/* + * Copyright contributors to Besu / AERE Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.lang.reflect.Field; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Properties; +import java.util.stream.Collectors; + +import org.apache.tuweni.bytes.Bytes; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * D-146, THE LINE THAT WAS MISSING. {@code PqRegistryHash.requireBindingsOrThrow} was delivered on + * 2026-08-06 with its own tests, and nothing called it. Its own javadoc said so: "NOT WIRED YET ... + * the call belongs beside AERE-PQC-REG-ARM-01 in FalconSealSupport, which is being edited by another + * stream". This class measures the wire. + * + *

WHAT THE WIRE BUYS, stated as the thing that is actually true. Before it, an ARMED node loaded + * a v1 registry without a word, and the registry decides who a Falcon seal is credited to. Measured + * on the real verification path on the same day: two rows with their public keys swapped - four + * distinct keys, four distinct addresses, so no uniqueness check would see anything - produced an + * ACCEPTED header; and the same key filed at two indices satisfied a threshold of two with one + * private key, which makes the threshold itself fiction. + * + *

WHY THE POSITIVE CONTROLS ARE THE EXPENSIVE HALF. A gate that refuses everything is not a gate, + * it is an outage wearing a security message. The tests that cost the most to get right here are the + * ones where the node STARTS: over a correct v2 registry, and over the very same v1 file when + * nothing is armed. + * + *

WHY THE ANCHOR CASE IS TESTED SEPARATELY FROM THE FORK-BLOCK CASE. They are different triggers + * and only one of them was previously guarded at all. {@code armingReadinessDiagnostic()} returns + * immediately when {@code aere.falcon.forkBlock} is unset, so AERE-PQC-REG-ARM-01 has never fired on + * a node armed through the certificate anchor. This guard fires on both, and {@link + * #armedThroughTheANCHORAloneTheNodeAlsoREFUSES} is the half that has no predecessor. + * + *

WHAT IS NOT MEASURED HERE, written rather than implied: nothing is deployed, no node is + * started, the fleet of seven is not touched, and every Falcon and ECDSA key below is a PROBE key + * generated in this JVM. Whether the refusal behaves the same on the seven real boxes at a + * coordinated restart is NOT MEASURED. + */ +public class PqArmingGateTest { + + /** The height at which this fixture arms Falcon blocking. */ + private static final long FORK = 7_000L; + + /** Attachment must lead the fork block; the same shape PqForkArmingTest uses. */ + private static final long ATTACH = 6_000L; + + /** The chain id the registry is bound to. Not 2800: nothing here may look like the live fleet. */ + private static final long CHAIN_ID = 220_878L; + + /** The height the binding proofs are signed for. */ + private static final long BIND_HEIGHT = FORK; + + private static final int N = 4; + + /** Every property this class is allowed to touch. Cleared before AND after every test. */ + private static final List OWNED_PROPERTIES = + List.of( + "aere.falcon.registry", + "aere.falcon.forkBlock", + "aere.falcon.attachBlock", + "aere.falcon.validatorCount", + "aere.falcon.testnetAllowSmallFleet", + PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, + PqAnchorConfig.PROPERTY_CHAIN_ID, + PqAnchorConfig.PROPERTY_MIN_SEALS); + + @TempDir private Path tmp; + + @BeforeEach + public void setUp() throws Exception { + clearOwnedProperties(); + forgetAnchorConfig(); + resetFalconSingleton(); + } + + @AfterEach + public void tearDown() throws Exception { + clearOwnedProperties(); + forgetAnchorConfig(); + resetFalconSingleton(); + } + + // ------------------------------------------------------------------------------------------- + // 1. THE FINDING, on each of the two arming triggers. + // ------------------------------------------------------------------------------------------- + + /** An armed node over a registry with no binding proofs must refuse to start. */ + @Test + public void armedOverAV1RegistryTheNodeREFUSESToStart() throws Exception { + armWithForkBlock(writeRegistry("registry-v1.properties", false, false)); + + assertThatThrownBy(FalconSealSupport::instance) + .describedAs( + "a v1 registry decides attribution by whoever wrote the file; arming over it is " + + "permanent, because the anchor contract is immutable once written") + .isInstanceOf(PqRegistryHash.RegistryConfigException.class) + .hasMessageContaining("AERE-PQC-REG-ARM-02"); + } + + /** + * The same refusal when the node is armed through the CERTIFICATE ANCHOR and {@code + * aere.falcon.forkBlock} is not set at all. + * + *

This is the case with no predecessor. AERE-PQC-REG-ARM-01 is raised by {@code + * armingReadinessDiagnostic()}, whose first statement is to return when the fork block is unset, + * so an anchor-armed node has never been asked ANY question about its registry's shape at startup. + */ + @Test + public void armedThroughTheANCHORAloneTheNodeAlsoREFUSES() throws Exception { + final Path v1 = writeRegistry("registry-v1.properties", false, false); + System.setProperty("aere.falcon.registry", v1.toAbsolutePath().toString()); + armWithAnchorOnly(); + + assertThat(System.getProperty("aere.falcon.forkBlock")) + .describedAs("this test is only worth something while the fork block is genuinely unset") + .isNull(); + assertThatThrownBy(FalconSealSupport::instance) + .isInstanceOf(PqRegistryHash.RegistryConfigException.class) + .hasMessageContaining("AERE-PQC-REG-ARM-02"); + } + + // ------------------------------------------------------------------------------------------- + // 2. THE POSITIVE CONTROLS. Without these the refusals above could be a load bug. + // ------------------------------------------------------------------------------------------- + + /** + * The same node, the same arming, over a registry whose every row carries a Falcon possession + * proof and an ECDSA claim signed by that row's own validator key, STARTS - and loads. + */ + @Test + public void armedOverAV2RegistryTheNodeSTARTS() throws Exception { + armWithForkBlock(writeRegistry("registry-v2.properties", true, true)); + + assertThatCode(FalconSealSupport::instance) + .describedAs( + "POSITIVE CONTROL: the gate can be green. A refusal that no correct input can pass is " + + "an outage wearing a security message") + .doesNotThrowAnyException(); + assertThat(FalconSealSupport.instance().registrySize()) + .describedAs("and it must really have loaded the file, not merely declined to throw") + .isEqualTo(N); + } + + /** The same, armed through the anchor alone. */ + @Test + public void armedThroughTheANCHORAloneOverAV2RegistryTheNodeSTARTS() throws Exception { + final Path v2 = writeRegistry("registry-v2.properties", true, true); + System.setProperty("aere.falcon.registry", v2.toAbsolutePath().toString()); + armWithAnchorOnly(); + + assertThatCode(FalconSealSupport::instance).doesNotThrowAnyException(); + assertThat(FalconSealSupport.instance().registrySize()).isEqualTo(N); + } + + // ------------------------------------------------------------------------------------------- + // 3. THE BOUNDARY. A node that arms NOTHING must be untouched by any of this. + // ------------------------------------------------------------------------------------------- + + /** + * THE GUARANTEE FOR CHAIN 2800 AS IT STANDS: a node with no {@code aere.pq.*} property and no + * {@code aere.falcon.forkBlock} starts over the very same v1 file that is refused when armed. + * + *

The assertion that carries the weight is not the "starts" - it is the property sweep. A test + * that only asserted "does not throw" would keep passing if a later edit made the guard read some + * other property that happened to be set in this JVM. The sweep states the precondition as a + * measurement: at the moment the constructor runs, NO system property beginning with {@code + * aere.pq.} exists, and neither does the fork block. + */ + @Test + public void withNothingArmedTheGateIsInertOverTheSameV1Registry() throws Exception { + final Path v1 = writeRegistry("registry-v1.properties", false, false); + System.setProperty("aere.falcon.registry", v1.toAbsolutePath().toString()); + + assertThat(systemPropertiesStartingWith("aere.pq.")) + .describedAs("the precondition of this test, measured rather than assumed") + .isEmpty(); + assertThat(System.getProperty("aere.falcon.forkBlock")).isNull(); + + assertThatCode(FalconSealSupport::instance) + .describedAs( + "the same file that is refused when armed is accepted when nothing is armed, so the " + + "trigger is ARMING and not the file") + .doesNotThrowAnyException(); + assertThat(FalconSealSupport.instance().registrySize()) + .describedAs("and an unarmed node's registry is loaded exactly as it was before D-146") + .isEqualTo(N); + } + + /** + * The same boundary with NO registry configured either, which is a node holding nothing at all - + * the shape of a fresh box joining the fleet before any key ceremony. + */ + @Test + public void aNodeWithNoFalconConfigurationAtAllStarts() { + assertThat(systemPropertiesStartingWith("aere.pq.")).isEmpty(); + assertThat(System.getProperty("aere.falcon.registry")).isNull(); + + assertThatCode(FalconSealSupport::instance).doesNotThrowAnyException(); + } + + /** + * An ARMED node with no registry file at all is deliberately NOT this guard's business, and this + * test is what stops that from being a silent decision. + * + *

D-146 is mis-ATTRIBUTION, which needs rows; an empty registry credits nobody. The condition + * is owned by AERE-PQC-CFG-UNSAFE-08 when the threshold is positive, and MEASURED here: with a + * threshold of zero, which is the warm-up regime the fleet is meant to arm INTO, the node starts. + * An earlier revision of this guard refused here, and the cost was exactly that - the intended + * activation procedure became unstartable. + */ + @Test + public void armedWithNoRegistryAtAllAndAZeroThresholdTheNodeStarts() { + System.setProperty(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, Long.toString(FORK)); + System.setProperty(PqAnchorConfig.PROPERTY_CHAIN_ID, Long.toString(CHAIN_ID)); + System.setProperty(PqAnchorConfig.PROPERTY_MIN_SEALS, FORK + ":0"); + System.setProperty("aere.falcon.validatorCount", Integer.toString(N)); + System.setProperty("aere.falcon.testnetAllowSmallFleet", "true"); + + assertThatCode(FalconSealSupport::instance) + .describedAs("K=0 over an empty registry is the warm-up regime, not a D-146 defect") + .doesNotThrowAnyException(); + } + + // ------------------------------------------------------------------------------------------- + // 4. HALF A v2 REGISTRY IS NOT A v2 REGISTRY. + // ------------------------------------------------------------------------------------------- + + /** + * A row that carries a Falcon possession proof and no ECDSA claim proves that SOMEBODY holds the + * key, and says nothing about which validator asked for it - which is the whole of D-146. + * + *

MEASURED, and the assertion was CHANGED to match the measurement rather than the other way + * round. The expectation written first was AERE-PQC-REG-ARM-02. What actually happens is a refusal + * one step EARLIER, at load, with AERE-PQC-REG-LOAD-21, because the loader counts proofs against + * claims and refuses a half-bound file before the arming gate ever sees it. That is the stronger + * of the two refusals - it holds whether or not the node is armed - so this is asserted on the + * code that actually fires. + */ + @Test + public void possessionWithoutAClaimIsRefusedEarlierStillAtLoad() throws Exception { + armWithForkBlock(writeRegistry("registry-possession-only.properties", true, false)); + + assertThatThrownBy(FalconSealSupport::instance) + .describedAs( + "the attacker is the key holder, so a genuine possession proof over a lying row is " + + "genuinely produceable; only the validator's own signature closes it") + .isInstanceOf(PqRegistryHash.RegistryConfigException.class) + .hasMessageContaining("AERE-PQC-REG-LOAD-21"); + } + + // ------------------------------------------------------------------------------------------- + // Helpers. + // ------------------------------------------------------------------------------------------- + + /** Arm through {@code aere.falcon.forkBlock}, the trigger AERE-PQC-REG-ARM-01 also watches. */ + private void armWithForkBlock(final Path registry) { + System.setProperty("aere.falcon.registry", registry.toAbsolutePath().toString()); + System.setProperty("aere.falcon.forkBlock", Long.toString(FORK)); + System.setProperty("aere.falcon.attachBlock", Long.toString(ATTACH)); + System.setProperty("aere.falcon.validatorCount", Integer.toString(N)); + // N=4 is below the blocking minimum; this fixture is an isolated network and says so with the + // switch the codebase already uses for exactly that, rather than by pretending to be seven. + System.setProperty("aere.falcon.testnetAllowSmallFleet", "true"); + } + + /** + * Arm through the CERTIFICATE ANCHOR only, leaving {@code aere.falcon.forkBlock} unset. The + * threshold is 2, which {@code worstCaseKeyedSigners(4, 4)} = 3 guarantees, so the D-078 guard + * next door stays silent and cannot be mistaken for this one. + */ + private void armWithAnchorOnly() { + System.setProperty(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, Long.toString(FORK)); + System.setProperty(PqAnchorConfig.PROPERTY_CHAIN_ID, Long.toString(CHAIN_ID)); + System.setProperty(PqAnchorConfig.PROPERTY_MIN_SEALS, FORK + ":0," + (FORK + 10L) + ":2"); + System.setProperty("aere.falcon.validatorCount", Integer.toString(N)); + System.setProperty("aere.falcon.testnetAllowSmallFleet", "true"); + } + + /** + * Write a registry in the legacy properties form. {@code withPossession} and {@code withClaim} are + * separate so that the half-bound case can be built, which is the one the loader must refuse. + */ + private Path writeRegistry( + final String name, final boolean withPossession, final boolean withClaim) throws Exception { + final StringBuilder b = new StringBuilder(); + if (withPossession || withClaim) { + b.append("formatVersion=").append(PqRegistryBinding.FORMAT_VERSION).append('\n'); + b.append("chainId=").append(CHAIN_ID).append('\n'); + b.append("bindHeight=").append(BIND_HEIGHT).append('\n'); + } + b.append("count=").append(N).append('\n'); + for (int i = 0; i < N; i++) { + b.append(i).append('=').append(unprefixed(PqV2Fixture.publicKey(i))).append('\n'); + b.append(i) + .append(".addr=") + .append(unprefixed(PqV2Fixture.address(i).getBytes().toArray())) + .append('\n'); + if (withPossession) { + b.append(i) + .append(".pop=") + .append(strip(PqV2Fixture.popHex(CHAIN_ID, BIND_HEIGHT, N, i))) + .append('\n'); + } + if (withClaim) { + b.append(i) + .append(".claim=") + .append(strip(PqV2Fixture.claimHex(CHAIN_ID, BIND_HEIGHT, N, i))) + .append('\n'); + } + } + final Path f = tmp.resolve(name); + Files.writeString(f, b.toString(), StandardCharsets.UTF_8); + return f; + } + + private static String unprefixed(final byte[] b) { + return Bytes.wrap(b).toUnprefixedHexString(); + } + + private static String strip(final String hex) { + return hex.startsWith("0x") ? hex.substring(2) : hex; + } + + /** Every system property name with the given prefix, so a precondition can be MEASURED. */ + private static List systemPropertiesStartingWith(final String prefix) { + final Properties p = System.getProperties(); + return p.stringPropertyNames().stream() + .filter(n -> n.startsWith(prefix)) + .sorted() + .collect(Collectors.toList()); + } + + private static void clearOwnedProperties() { + for (final String p : OWNED_PROPERTIES) { + System.clearProperty(p); + } + } + + /** + * Force the anchor configuration to be re-read from system properties. + * + *

MEASURED 2026-08-06, and it is the reason this method exists rather than being assumed + * unnecessary. {@code PqAnchorProducer.config()} memoises the first configuration it ever builds, + * for the life of the JVM. That is CORRECT in production - a node is one JVM with one set of + * properties, and a configuration that could change underneath the consensus path would be worse + * than one that cannot. In a test JVM shared by every class in this module it means an anchor + * armed by an earlier test is still armed here, and {@link + * #withNothingArmedTheGateIsInertOverTheSameV1Registry} failed exactly that way before this call + * was added: the property sweep found no {@code aere.pq.*} and the node still refused, because + * the memo held another class's anchor. + */ + private static void forgetAnchorConfig() { + org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer.useConfigForTesting( + null); + } + + private static void resetFalconSingleton() throws Exception { + final Field f = FalconSealSupport.class.getDeclaredField("instance"); + f.setAccessible(true); + f.set(null, null); + } +} diff --git a/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqCallerIntentTest.java b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqCallerIntentTest.java new file mode 100755 index 000000000..3800bfef1 --- /dev/null +++ b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqCallerIntentTest.java @@ -0,0 +1,386 @@ +/* + * Copyright contributors to Besu / AERE Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer; +import org.hyperledger.besu.datatypes.Address; + +import java.lang.reflect.Field; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.OptionalInt; + +import org.apache.tuweni.bytes.Bytes; +import org.apache.tuweni.bytes.Bytes32; +import org.bouncycastle.crypto.digests.KeccakDigest; +import org.bouncycastle.pqc.crypto.falcon.FalconPrivateKeyParameters; +import org.bouncycastle.pqc.crypto.falcon.FalconSigner; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * D2 HARDENING (b-v2). The repair of the repair: the caller's MOTIVE decides, not the height. + * + *

WHAT THE FIRST SHAPE DID, MEASURED AND NOT ARGUED. On 2026-08-06 hardening (b) refused every + * unbound height at or above the arming height, deciding from the block NUMBER alone. Run against + * the suite that gave 588 tests and 0 failures on a clean tree, it gave 597 tests and 6 failures: + * five in {@code PqSealPersistenceTest} and one in {@code PqForkValidatorSetChangeTest}. Both + * classes work on THIS NODE'S OWN head - restarting and re-reading its own seal file, and proposing + * on top of its own head - and in all six the number handed to the guard was 1030 with an arming + * height of 1000. A genuinely historical question, in the same process in the same second, hands + * the guard exactly those numbers too. No arithmetic on the height separates them. + * + *

THE OPERATIONAL CONSEQUENCE, in the words of the D078 failure itself: {@code refusing to + * propose on top of block 1030 because this node holds 0 valid eligible Falcon seal(s)}. The first + * shape turned a defect that is invisible on a running fleet and fatal only to a node syncing later + * into one that stops block production on all seven, in the minute the anchor is armed. + * + *

WHAT SEPARATES THEM IS WHO SUPPLIES THE SUBJECT, and that is known at every call site and was + * being discarded at the interface boundary. So {@code PqSignerRegistry} now carries two named + * pairs, and the compiler forces every call site to say which question it is asking. This class is + * the proof that the two doors answer DIFFERENTLY at the SAME height, that the own-head door is not + * a loophole, and that the history door still refuses. + * + *

THIS CLASS CANNOT GO GREEN BY ACCIDENT. Three of its tests fail if the own-head door is made + * to refuse (which is the first shape restored), and three fail if the history door is made to + * answer (which is the pre-2026-08-06 defect restored). The two plants are run in opposite + * directions and both are recorded in the evidence directory. + */ +public class PqCallerIntentTest { + + /** Anchor activation height H, matching the fixture the six failures ran under. */ + private static final long H = 1_000L; + + /** Height from which the staged threshold is non-zero, i.e. the fully armed regime. */ + private static final long K_AT = H + 10L; + + /** + * The height the six failures actually presented to the guard: this node's own head, above the + * arming height. Named for what it is, because the whole point is that the NUMBER is innocent. + */ + private static final long OWN_HEAD = 1_030L; + + /** A height far above H, standing in for "a year of history above the arming height". */ + private static final long DEEP = K_AT + 5_000L; + + private static final int N = 7; + + private static final long CHAIN_ID = 220_878L; + + @TempDir private Path tmp; + + private final List privateKeys = new ArrayList<>(); + private final List

validators = new ArrayList<>(); + private Path genesisPath; + + /** A fixed 32-byte message, standing in for M(parent) or a committed-seal hash. */ + private static final Bytes32 MESSAGE = Bytes32.fromHexString("0x" + "5a".repeat(32)); + + private Bytes sealByIndexZero; + + @BeforeEach + public void setUp() throws Exception { + // AERE D-146 (2026-08-06): a v2, PROOF-BOUND registry. It used to be v1 with addresses spelled + // 0xA00+i, which no secp256k1 key can sign for, so this fixture described a fleet that could + // never satisfy AERE-PQC-REG-ARM-02 once that guard was wired. The registry is bound at H, the + // height this fixture arms the anchor from. + final KeccakDigest kd = new KeccakDigest(256); + final StringBuilder manifest = new StringBuilder(); + manifest + .append("{\"config\":{\"aereFalconRegistry\":{") + .append(PqV2Fixture.manifestHeader(N, CHAIN_ID, H)); + for (int i = 0; i < N; i++) { + privateKeys.add(PqV2Fixture.privateKey(i)); + validators.add(PqV2Fixture.address(i)); + final byte[] anchoredRow = PqV2Fixture.anchorPreimageRow(i); + kd.update(anchoredRow, 0, anchoredRow.length); + manifest.append(',').append(PqV2Fixture.manifestEntry(i, N, CHAIN_ID, H)); + } + manifest + .append("}},\"alloc\":{\"0000000000000000000000000000000000000fa1\":{\"storage\":{\"0x") + .append("0".repeat(64)) + .append("\":\"0x"); + final byte[] anchoredHash = new byte[32]; + kd.doFinal(anchoredHash, 0); + manifest.append(Bytes.wrap(anchoredHash).toUnprefixedHexString()).append("\"}}}}"); + + genesisPath = tmp.resolve("genesis-d2v2.json"); + Files.writeString(genesisPath, manifest.toString()); + System.setProperty("aere.falcon.genesis", genesisPath.toAbsolutePath().toString()); + + final FalconSigner signer = new FalconSigner(); + signer.init(true, privateKeys.get(0)); + sealByIndexZero = Bytes.wrap(signer.generateSignature(MESSAGE.toArray())); + + resetFalconSingleton(); + // Exactly the montage the six failures ran under: armed at 1000, K staged at 1010, own head + // 1030, and NO config.pqRegistryHash anywhere - the state of every node on chain 2800 today. + PqAnchorProducer.useConfigForTesting( + new PqAnchorConfig(CHAIN_ID, H, Map.of(H, 0, K_AT, 3), OptionalInt.empty(), false)); + } + + @AfterEach + public void tearDown() throws Exception { + System.clearProperty("aere.falcon.genesis"); + System.clearProperty("aere.pq.genesis"); + System.clearProperty(FalconSealSupport.PROPERTY_REGISTRY_HISTORY); + resetFalconSingleton(); + PqAnchorProducer.useConfigForTesting(null); + } + + // ------------------------------------------------------------------------------------------- + // 0. The fixture. Without this a green run below could mean the registry never loaded at all. + // ------------------------------------------------------------------------------------------- + + @Test + public void baselineTheFixtureIsGenesisAnchoredAndTheSealIsGENUINE() { + final FalconSealSupport pqc = FalconSealSupport.instance(); + assertThat(pqc.genesisAnchored()) + .describedAs("the fixture must load a GENESIS-ANCHORED registry, or nothing here means anything") + .isTrue(); + assertThat(pqc.addressBound()).isTrue(); + assertThat(pqc.verify(0, MESSAGE, sealByIndexZero)) + .describedAs("the seal must be a REAL Falcon signature under the head registry") + .isTrue(); + } + + // ------------------------------------------------------------------------------------------- + // 1. THE WHOLE REPAIR, IN ONE ASSERTION. Same height, same index, same signature, same instant. + // Two answers, because two different questions were asked. + // ------------------------------------------------------------------------------------------- + + @Test + public void theSameHeightGivesTwoAnswersBecauseTheQUESTIONSDIFFER() { + final FalconSealSupport pqc = FalconSealSupport.instance(); + + assertThat(pqc.verifyAtHistoric(OWN_HEAD, 0, MESSAGE, sealByIndexZero)) + .describedAs( + "HISTORY door at 1030, armed from 1000, no schedule: a node judging somebody else's " + + "header cannot say which keys were in force there, so it REFUSES. Answering from " + + "the head registry here is D2/T2 verbatim") + .isFalse(); + + assertThat(pqc.verifyAtOwnHead(OWN_HEAD, 0, MESSAGE, sealByIndexZero)) + .describedAs( + "OWN-HEAD door, SAME height, SAME seal, same instant: this node's own head, where the " + + "head registry IS the answer by construction. Refusing here is what stopped the " + + "proposer and the restart path on 2026-08-06, and it bought no security: the " + + "certificate is re-checked by the other six through the history door") + .isTrue(); + + assertThat(pqc.addressForIndexAtHistoric(OWN_HEAD, 0)) + .describedAs("the address halves must split the same way, or R2 and the producer disagree") + .isNull(); + assertThat(pqc.addressForIndexAtOwnHead(OWN_HEAD, 0)).isEqualTo(validators.get(0)); + } + + // ------------------------------------------------------------------------------------------- + // 2. The restart path, which is five of the six failures. PqSealStore has already forced the + // stored block number and hash to equal this node's head before it asks. + // ------------------------------------------------------------------------------------------- + + @Test + public void theRESTARTPathAnswersAtAnArmedHeightWithNoSchedule() { + final FalconSealSupport pqc = FalconSealSupport.instance(); + assertThat(pqc.verifyAtOwnHead(OWN_HEAD, 0, MESSAGE, sealByIndexZero)) + .describedAs( + "PqSealPersistenceTest restored 0 of 3 genuine seals under the first shape. A node " + + "that cannot re-read its own seal file after a restart is a node that cannot " + + "propose, and the file is the documented way out of the D-141 deadlock") + .isTrue(); + assertThat(pqc.addressForIndexAtOwnHead(OWN_HEAD, 0)) + .describedAs("and the index must bind, or every stored seal is dropped as unknown") + .isEqualTo(validators.get(0)); + } + + // ------------------------------------------------------------------------------------------- + // 3. The proposer path, the sixth failure. PqAnchorProducer resolves at the PARENT's height, + // and the parent is this node's own head. + // ------------------------------------------------------------------------------------------- + + @Test + public void thePROPOSERPathAnswersAtAnArmedHeightWithNoSchedule() { + final FalconSealSupport pqc = FalconSealSupport.instance(); + for (int i = 0; i < 5; i++) { + final FalconSigner s = new FalconSigner(); + s.init(true, privateKeys.get(i)); + final Bytes sealI = Bytes.wrap(s.generateSignature(MESSAGE.toArray())); + assertThat(pqc.verifyAtOwnHead(OWN_HEAD, i, MESSAGE, sealI)) + .describedAs( + "all K=5 genuine seals must resolve, or PqAnchorProducer throws " + + "PqAnchorNotReadyException with '5 were not eligible signers' and the node " + + "stops producing blocks - which is exactly what was measured") + .isTrue(); + assertThat(pqc.addressForIndexAtOwnHead(OWN_HEAD, i)).isEqualTo(validators.get(i)); + } + } + + // ------------------------------------------------------------------------------------------- + // 4. THE OWN-HEAD DOOR IS NOT A LOOPHOLE. This is the assertion that has to hold for the form to + // be worth anything: a configured epoch this node does NOT hold fails closed on BOTH doors. + // ------------------------------------------------------------------------------------------- + + @Test + public void theOwnHeadDoorIsNOTALoopholeAnUnheldEpochIsRefusedOnBOTHDOORS() throws Exception { + final FalconSealSupport pqc = FalconSealSupport.instance(); + final PqRegistryHash.Registry held = PqRegistryHash.loadAuto(genesisPath); + final String hash = PqRegistryHash.hashFor(held, CHAIN_ID); + final long rotation = K_AT + 1_000L; + + final Map entries = new LinkedHashMap<>(); + entries.put(H, hash); + entries.put(rotation, "0x" + "cd".repeat(32)); + pqc.verifyRegistryBindingOrAbort(0L, CHAIN_ID, scheduleFromGenesis(entries)); + + assertThat(pqc.verifyAtOwnHead(rotation, 0, MESSAGE, sealByIndexZero)) + .describedAs( + "a rotation the chain HAS scheduled and this node does NOT hold is not a missing " + + "binding, it is a node running a registry the chain has moved off. If the " + + "own-head door answered here it would be a way to sign blocks under a retired " + + "key set, and the split would have bought a liveness fix at the price of the " + + "property the whole anchor exists for") + .isFalse(); + assertThat(pqc.addressForIndexAtOwnHead(rotation, 0)).isNull(); + + assertThat(pqc.verifyAtHistoric(rotation, 0, MESSAGE, sealByIndexZero)) + .describedAs("and the history door refuses identically") + .isFalse(); + + assertThat(pqc.verifyAtOwnHead(rotation - 1L, 0, MESSAGE, sealByIndexZero)) + .describedAs("positive control: below the rotation this node holds the epoch and answers") + .isTrue(); + assertThat(pqc.verifyAtHistoric(rotation - 1L, 0, MESSAGE, sealByIndexZero)).isTrue(); + } + + // ------------------------------------------------------------------------------------------- + // 5. Negative control on the split itself: with the binding CONFIGURED, the two doors converge. + // If they do not, the own-head door is not a fallback rule, it is a second key set. + // ------------------------------------------------------------------------------------------- + + @Test + public void withTheScheduleConfiguredBOTHDOORSGiveTheSameAnswer() throws Exception { + final FalconSealSupport pqc = FalconSealSupport.instance(); + final PqRegistryHash.Registry held = PqRegistryHash.loadAuto(genesisPath); + final Map entries = new LinkedHashMap<>(); + // AERE D-146 (2026-08-06): hashFor, not hashV1. A schedule entry has to carry the canonical + // hash OF THE REGISTRY IT NAMES, and this fixture's registry is now v2, which hashes under a + // different domain tag. MEASURED: leaving hashV1 here made the entry name a registry nobody + // holds, and the height-resolved lookups fell through to a refusal - a green test turning red + // for a reason that had nothing to do with what it measures. This is the same breakage a real + // genesis takes: any config.pqRegistryHash computed before the registry was rebuilt as v2 + // stops matching the moment it is rebuilt. + entries.put(H, PqRegistryHash.hashFor(held, CHAIN_ID)); + pqc.verifyRegistryBindingOrAbort(0L, CHAIN_ID, scheduleFromGenesis(entries)); + + for (final long h : new long[] {H - 1L, H, OWN_HEAD, DEEP}) { + assertThat(pqc.verifyAtHistoric(h, 0, MESSAGE, sealByIndexZero)) + .describedAs( + "at height " + + h + + " with the epoch bound at the arming height, the history door resolves through " + + "the SCHEDULE, not through any fallback") + .isTrue(); + assertThat(pqc.verifyAtOwnHead(h, 0, MESSAGE, sealByIndexZero)) + .describedAs( + "and the own-head door gives the SAME answer at height " + + h + + ". The two doors differ only in what they do when NOTHING binds the height. If " + + "they differed with a binding in force, the split would have introduced a " + + "second key set rather than a second failure mode") + .isTrue(); + assertThat(pqc.addressForIndexAtHistoric(h, 0)).isEqualTo(pqc.addressForIndexAtOwnHead(h, 0)); + } + } + + // ------------------------------------------------------------------------------------------- + // 6. The live fleet is untouched. aere.pq.anchorBlock is unset on all seven today, so there is + // no arming height, and BOTH doors answer exactly as they did before either hardening. + // ------------------------------------------------------------------------------------------- + + @Test + public void whenTheAnchorIsNotArmedBOTHDOORSAnswerAndNOTHINGCHANGES() { + PqAnchorProducer.useConfigForTesting(PqAnchorConfig.never(CHAIN_ID)); + final FalconSealSupport pqc = FalconSealSupport.instance(); + assertThat(pqc.verifyAtHistoric(DEEP, 0, MESSAGE, sealByIndexZero)).isTrue(); + assertThat(pqc.verifyAtOwnHead(DEEP, 0, MESSAGE, sealByIndexZero)).isTrue(); + assertThat(pqc.addressForIndexAtHistoric(DEEP, 0)).isEqualTo(validators.get(0)); + assertThat(pqc.addressForIndexAtOwnHead(DEEP, 0)).isEqualTo(validators.get(0)); + } + + // ------------------------------------------------------------------------------------------- + // 7. Below the arming height nothing is being judged, so both doors answer. This is the + // assertion that goes red first if anybody makes the history door refuse unconditionally. + // ------------------------------------------------------------------------------------------- + + @Test + public void belowTheArmingHeightBOTHDOORSAnswer() { + final FalconSealSupport pqc = FalconSealSupport.instance(); + assertThat(pqc.verifyAtHistoric(H - 1L, 0, MESSAGE, sealByIndexZero)).isTrue(); + assertThat(pqc.verifyAtOwnHead(H - 1L, 0, MESSAGE, sealByIndexZero)).isTrue(); + assertThat(pqc.verifyAtHistoric(0L, 0, MESSAGE, sealByIndexZero)).isTrue(); + assertThat(pqc.addressForIndexAtHistoric(H - 1L, 0)).isEqualTo(validators.get(0)); + assertThat(pqc.addressForIndexAtOwnHead(H - 1L, 0)).isEqualTo(validators.get(0)); + } + + // ------------------------------------------------------------------------------------------- + // Helpers. + // ------------------------------------------------------------------------------------------- + + /** + * Build a schedule the way a node really gets one: written into a genesis file as {@code + * config.pqRegistryHash} and parsed back, so the strictly-increasing rule in the parser is on the + * path rather than bypassed. + * + * @param entries height to 0x-prefixed registry hash, in ascending order of height + * @return the parsed schedule + * @throws Exception when the temporary genesis cannot be written + */ + private PqRegistryHash.Schedule scheduleFromGenesis(final Map entries) + throws Exception { + final List heights = new ArrayList<>(entries.keySet()); + heights.sort(Long::compare); + final StringBuilder sb = new StringBuilder("{\"config\":{\"pqRegistryHash\":["); + for (int i = 0; i < heights.size(); i++) { + if (i > 0) { + sb.append(','); + } + final long b = heights.get(i); + String h = entries.get(b); + if (!h.startsWith("0x")) { + h = "0x" + h; + } + sb.append("{\"block\":").append(b).append(",\"hash\":\"").append(h).append("\"}"); + } + sb.append("]}}"); + final Path p = + tmp.resolve("genesis-schedule-v2-" + heights.size() + "-" + heights.get(0) + ".json"); + Files.writeString(p, sb.toString()); + return PqRegistryHash.loadScheduleFromGenesis(p); + } + + private static void resetFalconSingleton() throws Exception { + final Field f = FalconSealSupport.class.getDeclaredField("instance"); + f.setAccessible(true); + f.set(null, null); + } +} diff --git a/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqFleetRestartArmingTest.java b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqFleetRestartArmingTest.java new file mode 100755 index 000000000..aa60447bb --- /dev/null +++ b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqFleetRestartArmingTest.java @@ -0,0 +1,411 @@ +/* + * Copyright contributors to Besu / AERE Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.hyperledger.besu.crypto.SecureRandomProvider; +import org.hyperledger.besu.datatypes.Address; + +import java.lang.reflect.Field; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.SecureRandom; + +import org.apache.tuweni.bytes.Bytes; +import org.bouncycastle.crypto.AsymmetricCipherKeyPair; +import org.bouncycastle.crypto.digests.KeccakDigest; +import org.bouncycastle.pqc.crypto.falcon.FalconKeyGenerationParameters; +import org.bouncycastle.pqc.crypto.falcon.FalconKeyPairGenerator; +import org.bouncycastle.pqc.crypto.falcon.FalconParameters; +import org.bouncycastle.pqc.crypto.falcon.FalconPrivateKeyParameters; +import org.bouncycastle.pqc.crypto.falcon.FalconPublicKeyParameters; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * D-140. THE FLEET-RESTART DEADLOCK, AND THE STATE MACHINE THE REPAIR MOVES. + * + *

MEASURED FIRST, ON A NETWORK, NOT ASSUMED. The full activation rehearsal on a seven-node test + * network (repetitie-activare-2026-08-05) found that with the anchor armed at K>0 a SIMULTANEOUS + * restart of every validator stops the chain for good. The node said it verbatim: "refusing to + * propose ... holds 0 valid eligible Falcon seal(s) ... threshold is 3", over "Attachment stays OFF + * (fail-safe)". + * + *

THE CIRCLE. {@code activateLateAnchor()} used to be reachable from exactly one place, {@code + * FalconSealValidationRule.tryActivateLateAnchor}, which runs only while a block is being IMPORTED. + * After a fleet restart no block is imported, because nobody proposes. So {@code lateActivated} + * stays false, {@link FalconSealSupport#attachmentArmed(long)} answers false, no seal is attached, + * no certificate reaches K, and nobody can propose. Seals come from Commits, Commits come from + * proposals, proposals need seals. With K=0 the chain heals itself. With K>0 it never does. + * + *

WHAT THIS CLASS MEASURES, and it is the state machine the repair moves, not a paraphrase of + * it. The repair (QbftBesuControllerBuilder, marker "AERE BLOCAJ-REPORNIRE") adds a SECOND caller of + * the SAME method at startup, reading the SAME contract slot 0 out of the chain-head world state. + * So the question that decides whether the repair can work is exactly: does calling {@code + * activateLateAnchor} with the on-chain hash, with no block imported and no other stimulus, turn + * {@code attachmentArmed()} from false to true. Below, it does. + * + *

    + *
  1. {@link #restartedFleetIsNotArmedAndDoesNotHealWithTime()} - the deadlock state itself. A + * node whose late anchor is PENDING is past its attachment height and still refuses to + * attach, at that height and at every height after it. Nothing in the process flips it. + *
  2. {@link #activatingFromTheChainHeadArmsAttachment()} - the repair's mechanism. One call with + * the on-chain hash, and attachment is armed. This is the ONLY thing the startup code adds. + *
  3. {@link #aWrongOnChainHashLeavesAttachmentOffAndIsTerminal()} - THE NEGATIVE CONTROL. If the + * hash does not match, activation must FAIL and attachment must stay OFF: the repair must not + * have bought liveness by weakening the tamper check. It also stays terminally failed, so a + * later correct hash does not resurrect it. + *
  4. {@link #activationIsIdempotentAcrossRepeatedStartupCalls()} - the scope control. The + * startup call and the import-path call can both fire in one process; the second must be a + * no-op rather than a second registry load. + *
  5. {@link #aGenesisAnchoredNodeIsArmedImmediatelyAfterRestart()} - the rehearsal's own + * stimulus replayed against THIS tree, and it does not fail the way the network did. Read its + * javadoc: the rehearsal binary predates D-078, and the line it logged came from a condition + * this tree no longer contains. + *
+ * + *

NOT MEASURED here, and named so it is not read as covered: that a real Besu process reads slot + * 0 out of a real chain-head world state (that is world-state plumbing in the app module, and the + * rehearsal network is the instrument for it), and that seven live nodes recover from a real + * simultaneous restart with this binary. This class measures the decision the deadlock hinges on. + */ +// The D-140 label is our internal finding id. It names a fact about this +// code, not anything outside it. +public class PqFleetRestartArmingTest { + + /** + * Fleet size for THIS fixture. Not a statement about any live network: the 2026-08-05 decision + * to stay at seven was reversed, and the set has been nine since 2026-08-12. Seven is kept here + * because it is the size at which the margin arithmetic this class exercises is tightest. + */ + private static final int N = 7; + + /** Height at which the anchor contract is expected to be observable. */ + private static final long OBSERVE = 1_000L; + + /** Seal-attachment height, at or after OBSERVE. */ + private static final long ATTACH = 1_200L; + + /** A chain head well past the attachment height: this is what a restart comes back to. */ + private static final long HEAD = 5_000L; + + private static final String ANCHOR_ADDRESS = "0x0000000000000000000000000000000000000fa1"; + + @TempDir private Path tmp; + + /** keccak256 over (addr20 || pk) for every index in order: what the anchor contract holds. */ + private String onChainHash; + + /** The same registry, spelled as a GENESIS-anchored manifest (the rehearsal's own shape). */ + private Path genesisPath; + + @BeforeEach + public void setUp() throws Exception { + // DATED 2026-08-20. This class never arms the certificate anchor, but FalconSealSupport's + // constructor consults it (anchorArmedFrom() -> PqAnchorProducer.config(), a per-JVM cache): + // a neighbouring test class that leaves an ARMED anchor config cached in this JVM turns every + // proof-less fixture below into an AERE-PQC-REG-ARM-02 refusal. Measured on the production + // tree that day: this class ALONE 5/5 green, inside the full suite the same 5 red, identical + // sources -- the 2026-08-11 order-luck lesson verbatim ("clearing the properties does not + // clear the caches"). The defence belongs to the consumer: start from an unarmed anchor, + // cache and properties both. + for (final String p : System.getProperties().stringPropertyNames()) { + if (p.startsWith("aere.pq.")) { + System.clearProperty(p); + } + } + org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer.useConfigForTesting( + null); + resetFalconSingleton(); + + final SecureRandom rnd = SecureRandomProvider.createSecureRandom(); + final KeccakDigest kd = new KeccakDigest(256); + final StringBuilder manifest = new StringBuilder("{\"count\":").append(N); + final StringBuilder genesis = + new StringBuilder("{\"config\":{\"aereFalconRegistry\":{\"count\":").append(N); + for (int i = 0; i < N; i++) { + final FalconKeyPairGenerator gen = new FalconKeyPairGenerator(); + gen.init(new FalconKeyGenerationParameters(rnd, FalconParameters.falcon_512)); + final AsymmetricCipherKeyPair kp = gen.generateKeyPair(); + final FalconPublicKeyParameters pub = (FalconPublicKeyParameters) kp.getPublic(); + final FalconPrivateKeyParameters priv = (FalconPrivateKeyParameters) kp.getPrivate(); + final Address addr = Address.fromHexString(String.format("0x%040x", 0xA00 + i)); + + // The pre-image is accumulated in lockstep with the manifest text, exactly the way a real + // anchoring transaction is built, so the hash below is not copied out of the code under test. + final byte[] addrBytes = addr.getBytes().toArray(); + kd.update(addrBytes, 0, addrBytes.length); + kd.update(pub.getH(), 0, pub.getH().length); + + final String entry = + ",\"" + + i + + "\":{\"addr\":\"" + + addr.toHexString() + + "\",\"pk\":\"" + + Bytes.wrap(pub.getH()).toHexString() + + "\"}"; + manifest.append(entry); + genesis.append(entry); + + if (i == 0) { + // This node is validator 0 and HOLDS a signing key, otherwise attachment is off for a + // reason that has nothing to do with the deadlock and the measurement would be vacuous. + final Path key0 = tmp.resolve("falcon-key-0.properties"); + Files.writeString( + key0, + "index=0\n" + + "f=" + + Bytes.wrap(priv.getSpolyf()).toHexString() + + "\n" + + "g=" + + Bytes.wrap(priv.getG()).toHexString() + + "\n" + + "F=" + + Bytes.wrap(priv.getSpolyF()).toHexString() + + "\n" + + "pk=" + + Bytes.wrap(pub.getH()).toHexString() + + "\n"); + System.setProperty("aere.falcon.key", key0.toAbsolutePath().toString()); + } + } + manifest.append("}"); + + final byte[] digest = new byte[32]; + kd.doFinal(digest, 0); + onChainHash = Bytes.wrap(digest).toUnprefixedHexString(); + + final Path manifestPath = tmp.resolve("falcon-late-manifest.json"); + Files.writeString(manifestPath, manifest.toString()); + + // Same seven entries, anchored the way the rehearsal network anchored them: in genesis, with + // the hash committed in the anchor contract's slot 0 through alloc storage. + genesis + .append("}},\"alloc\":{\"0000000000000000000000000000000000000fa1\":{\"storage\":{\"0x") + .append("0".repeat(64)) + .append("\":\"0x") + .append(onChainHash) + .append("\"}}}}"); + genesisPath = tmp.resolve("genesis-registry.json"); + Files.writeString(genesisPath, genesis.toString()); + + System.setProperty("aere.falcon.manifest", manifestPath.toAbsolutePath().toString()); + System.setProperty("aere.falcon.anchor.address", ANCHOR_ADDRESS); + System.setProperty("aere.falcon.anchor.block", Long.toString(OBSERVE)); + System.setProperty("aere.falcon.attachBlock", Long.toString(ATTACH)); + System.setProperty("aere.falcon.validatorCount", Integer.toString(N)); + } + + @AfterEach + public void tearDown() throws Exception { + for (final String p : + new String[] { + "aere.falcon.manifest", + "aere.falcon.genesis", + "aere.falcon.key", + "aere.falcon.anchor.address", + "aere.falcon.anchor.block", + "aere.falcon.attachBlock", + "aere.falcon.forkBlock", + "aere.falcon.validatorCount" + }) { + System.clearProperty(p); + } + resetFalconSingleton(); + } + + // ------------------------------------------------------------------------------------------- + // 1. The deadlock state, stated as a property. + // ------------------------------------------------------------------------------------------- + + @Test + public void restartedFleetIsNotArmedAndDoesNotHealWithTime() { + final FalconSealSupport pqc = FalconSealSupport.instance(); + + assertThat(pqc.lateAnchorPending()) + .describedAs( + "fixture control: the late-anchor manifest must LOAD and stay PENDING, or every " + + "assertion below is about a registry that was never configured") + .isTrue(); + assertThat(pqc.lateAnchored()).isFalse(); + assertThat(pqc.lateAnchorFailed()).isFalse(); + assertThat(pqc.signingEnabled()) + .describedAs("fixture control: this node holds a Falcon key, so attachment is not off for " + + "the trivial reason") + .isTrue(); + assertThat(pqc.attachBlock()).isEqualTo(ATTACH); + + // This IS the post-restart state: the process has just started, the chain head is far past the + // attachment height, and no block has been imported because nobody has proposed one. + assertThat(pqc.attachmentArmed(HEAD)) + .describedAs( + "the measured deadlock: attachment height long since passed, registry still pending, " + + "so no seal is attached and no certificate can ever reach K") + .isFalse(); + + // And it does not heal. Time, and blocks that are never imported, change nothing. + for (long n = HEAD; n <= HEAD + 10_000L; n += 1_000L) { + assertThat(pqc.attachmentArmed(n)) + .describedAs("still not armed at height %s; nothing in the process flips it", n) + .isFalse(); + } + assertThat(pqc.registrySize()) + .describedAs("the registry is EMPTY while pending, which is why a seal cannot verify either") + .isZero(); + } + + // ------------------------------------------------------------------------------------------- + // 2. The repair's mechanism: the SECOND caller, the one startup adds. + // ------------------------------------------------------------------------------------------- + + @Test + public void activatingFromTheChainHeadArmsAttachment() { + final FalconSealSupport pqc = FalconSealSupport.instance(); + assertThat(pqc.attachmentArmed(HEAD)).isFalse(); + + // Exactly what the startup repair does: hand over the 32-byte value read from the anchor + // contract's slot 0 in the CHAIN-HEAD world state. No block is imported anywhere here. + final boolean activated = pqc.activateLateAnchor(onChainHash); + + assertThat(activated).isTrue(); + assertThat(pqc.lateAnchored()).isTrue(); + assertThat(pqc.lateAnchorPending()).isFalse(); + assertThat(pqc.registrySize()).isEqualTo(N); + assertThat(pqc.addressBound()) + .describedAs("the activated registry must bind every index to an address, or a seal cannot " + + "be resolved to a signer") + .isTrue(); + assertThat(pqc.attachmentArmed(HEAD)) + .describedAs( + "THE REPAIR: one activation from chain-head state arms attachment, so a restarted " + + "validator emits Falcon-carrying Commits again, certificates reach K, and a " + + "proposer can propose. This is the edge the deadlock needed and did not have.") + .isTrue(); + } + + // ------------------------------------------------------------------------------------------- + // 3. THE NEGATIVE CONTROL. The repair must not have bought liveness by weakening the check. + // ------------------------------------------------------------------------------------------- + + @Test + public void aWrongOnChainHashLeavesAttachmentOffAndIsTerminal() { + final FalconSealSupport pqc = FalconSealSupport.instance(); + + // One flipped nibble: a tampered anchor, or a wrong manifest shipped to this node. + final char first = onChainHash.charAt(0); + final String wrong = (first == '0' ? '1' : '0') + onChainHash.substring(1); + assertThat(wrong).isNotEqualTo(onChainHash).hasSize(64); + + assertThat(pqc.activateLateAnchor(wrong)) + .describedAs("a mismatching anchor must NOT activate the registry") + .isFalse(); + assertThat(pqc.lateAnchored()).isFalse(); + assertThat(pqc.lateAnchorFailed()) + .describedAs("and the mismatch must be TERMINAL, not merely 'not yet'") + .isTrue(); + assertThat(pqc.registrySize()) + .describedAs("the registry stays EMPTY: fail-closed, not fail-open") + .isZero(); + assertThat(pqc.attachmentArmed(HEAD)) + .describedAs( + "attachment stays OFF after a failed activation. If this were true, the startup repair " + + "would have turned a tamper detection into an arming path.") + .isFalse(); + + // And the correct hash afterwards does not resurrect it: a node that has seen a tampered anchor + // stays refused, which is the same fail-closed rule the import path already had. + assertThat(pqc.activateLateAnchor(onChainHash)).isFalse(); + assertThat(pqc.attachmentArmed(HEAD)).isFalse(); + } + + // ------------------------------------------------------------------------------------------- + // 4. Scope control: two callers now exist in one process. + // ------------------------------------------------------------------------------------------- + + @Test + public void activationIsIdempotentAcrossRepeatedStartupCalls() { + final FalconSealSupport pqc = FalconSealSupport.instance(); + + assertThat(pqc.activateLateAnchor(onChainHash)).isTrue(); + final int afterFirst = pqc.registrySize(); + + // The startup call has fired; the import path fires too, on the first block that arrives. + assertThat(pqc.activateLateAnchor(onChainHash)).isTrue(); + assertThat(pqc.registrySize()).isEqualTo(afterFirst).isEqualTo(N); + assertThat(pqc.attachmentArmed(HEAD)).isTrue(); + + // Even a garbage hash after activation cannot un-arm it: activation is a one-way latch, so a + // second reader with a stale view cannot disarm a fleet that is already sealing. + assertThat(pqc.activateLateAnchor("00".repeat(32))).isTrue(); + assertThat(pqc.lateAnchorFailed()).isFalse(); + assertThat(pqc.attachmentArmed(HEAD)).isTrue(); + } + + // ------------------------------------------------------------------------------------------- + // 5. The rehearsal's OWN stimulus, replayed against THIS tree. Read the note before trusting it. + // ------------------------------------------------------------------------------------------- + + /** + * The seven-node rehearsal ran a GENESIS-anchored registry, and the line it logged after the + * simultaneous restart was the COVERAGE one: "no validator set has been observed yet, so registry + * COVERAGE cannot be proven. Attachment stays OFF (fail-safe)". That condition does not exist in + * this tree: {@code grep} for it returns nothing, because D-078 (2026-08-02) removed the fleet + * question from the per-commit gate. The rehearsal binary was built from the 2026-08-01 tree, + * which still had it. + * + *

So this test states what is true HERE: a genesis-anchored node, freshly constructed, with no + * validator set observed and no block imported, IS armed. The rehearsal's measured deadlock is + * closed for the genesis-anchored path by a repair that already landed - and NOT by the startup + * repair this class is about. + * + *

Which is exactly why the startup repair is still needed: on the LATE-ANCHOR path, the one + * the live chain must use because it cannot be re-genesised, {@code lateActivated} is still set + * from one place only. Tests 1-3 measure that path. + * + *

NOT MEASURED: that seven live nodes on a genesis-anchored network recover from a + * simultaneous restart with a binary built from this tree. + */ + @Test + public void aGenesisAnchoredNodeIsArmedImmediatelyAfterRestart() throws Exception { + System.clearProperty("aere.falcon.manifest"); + System.clearProperty("aere.falcon.anchor.address"); + System.clearProperty("aere.falcon.anchor.block"); + System.setProperty("aere.falcon.genesis", genesisPath.toAbsolutePath().toString()); + resetFalconSingleton(); + + final FalconSealSupport pqc = FalconSealSupport.instance(); + + assertThat(pqc.genesisAnchored()) + .describedAs("fixture control: the genesis manifest must verify against the anchored hash") + .isTrue(); + assertThat(pqc.registrySize()).isEqualTo(N); + assertThat(pqc.addressBound()).isTrue(); + assertThat(pqc.attachmentArmed(HEAD)) + .describedAs( + "a genesis-anchored node arms with NO validator set observed and NO block imported. " + + "The rehearsal's coverage condition is gone from this tree.") + .isTrue(); + } + + private static void resetFalconSingleton() throws Exception { + final Field f = FalconSealSupport.class.getDeclaredField("instance"); + f.setAccessible(true); + f.set(null, null); + } +} diff --git a/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqForkArmingTest.java b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqForkArmingTest.java new file mode 100755 index 000000000..f7276f91e --- /dev/null +++ b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqForkArmingTest.java @@ -0,0 +1,370 @@ +/* + * Copyright contributors to Besu / AERE Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + + +import java.lang.reflect.Field; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.apache.tuweni.bytes.Bytes; +import org.bouncycastle.crypto.digests.KeccakDigest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * D-079. THE MEASUREMENT THAT DID NOT EXIST. + * + *

The registry entry reads: "a malformed forkBlock falls OPEN, with only a log line, and arming + * it at or before the anchor observation height passes undetected", and it carried {@code verifica: + * NICIUNA} since 18 July. This file is the command that can fail. + * + *

Both halves of the finding are about the SAME shape of defect, the one the Holesky Pectra + * incident of February 2025 made expensive for everybody: a fork-activation parameter that is wrong + * or absent does not stop the node, it changes what the node silently believes. Half one is the + * value itself. Half two is the ORDER between that value and the height at which the registry the + * value depends on becomes active. + * + *

WHAT EACH TEST MEASURES, and how each can fail: + * + *

    + *
  1. {@link #controlAWellFormedLateAnchorConfigurationStarts()} - the fixture's own negative + * control. If the late-anchor manifest did not load, or the singleton were not really being + * rebuilt, every refusal below would be a refusal for the wrong reason. + *
  2. {@link #aMalformedForkBlockRefusesToStart()} and {@link #aNegativeForkBlockRefusesToStart()} + * - the config-time half of the finding, at construction. + *
  3. {@link #theForkBlockIsResolvedOnceAndCannotBeReopenedAfterStartup()} - the RESIDUAL half + * one. The startup guard only ever looked at the property once, but {@code forkBlock()} + * re-read the property on every call and fell back to "never blocking" with a log line on + * anything it could not parse. A guard that validates a value it does not then own is not a + * guard; this test drives that exact gap. + *
  4. {@link #blockingOverAPendingAnchorWithNoDeclaredObservationHeightRefuses()} - half two. A + * blocking height is stated over a registry that is not active yet and whose activation + * height is nowhere stated, so nothing in the process can compare the two. + *
  5. {@link #anAttachHeightBeforeTheObservationHeightRefuses()} and {@link + * #aForkHeightAtTheObservationHeightRefuses()} - half two on its own stimulus: the ordering + * is wrong and the node starts anyway. + *
  6. {@link #aMalformedObservationHeightRefuses()} and {@link + * #aNegativeObservationHeightRefuses()} - the new value must fail closed like every other + * {@code aere.falcon.*} value. A half-fail-closed property set is worse than either extreme. + *
  7. {@link #aGenesisAnchoredRegistryNeedsNoObservationHeight()} and {@link + * #anObservationHeightWithoutBlockingIsHarmless()} - the scope controls. A guard that refused + * every blocking configuration would pass every test above and be useless. + *
+ * + *

NOT MEASURED here, deliberately, and named so it is not mistaken for covered: whether a real + * Besu node process exits with a non-zero status when this exception is thrown. This class measures + * the decision, not the process. The exception is thrown from the constructor, on the same path as + * the guards that already abort, and nothing in this tree catches {@code + * FalconSealSupport.ActivationConfigException}. + */ +// The D-079 label is our internal finding id. It names a fact about this +// code, not anything outside it. +public class PqForkArmingTest { + + /** Fleet size; nine, because the blocking guard refuses to arm below nine. */ + private static final int N = 9; + + /** Height at which the on-chain late-anchor registry contract is expected to be observed. */ + private static final long OBSERVE = 5_000L; + + /** Seal-attachment height: at or after OBSERVE, so a seal can actually be emitted. */ + private static final long ATTACH = 6_000L; + + /** Blocking height: at least minAttachLead (256) after ATTACH. */ + private static final long FORK = 7_000L; + + private static final String ANCHOR_ADDRESS = "0x0000000000000000000000000000000000000fa1"; + + /** + * AERE D-146: the chain this fixture's registries are BOUND to. Every proof commits to it, so it + * has to be stated rather than defaulted. + */ + private static final long CHAIN_ID = 2_800L; + + @TempDir private Path tmp; + + private Path manifestPath; + private Path genesisPath; + + @BeforeEach + public void setUp() throws Exception { + // AERE D-146 (2026-08-06): both registries below are v2 and PROOF-BOUND, bound at FORK, the + // height this fixture arms from. They used to carry addresses spelled 0xB00+i, which no + // secp256k1 key can sign for, so this whole fixture became unstartable the moment + // AERE-PQC-REG-ARM-02 was wired into the constructor. + + // LATE-ANCHOR manifest: the registry is PENDING until the anchor contract is observed on chain. + final StringBuilder late = new StringBuilder("{"); + late.append(PqV2Fixture.manifestHeader(N, CHAIN_ID, FORK)); + for (int i = 0; i < N; i++) { + late.append(',').append(PqV2Fixture.manifestEntry(i, N, CHAIN_ID, FORK)); + } + late.append("}"); + manifestPath = tmp.resolve("falcon-late-manifest.json"); + Files.writeString(manifestPath, late.toString()); + + // GENESIS-ANCHORED manifest: the registry is ACTIVE from block 0, so no observation height can + // exist and none may be demanded. Built exactly the way a real genesis is, hash included. + final KeccakDigest kd = new KeccakDigest(256); + final StringBuilder gen = new StringBuilder("{\"config\":{\"aereFalconRegistry\":{"); + gen.append(PqV2Fixture.manifestHeader(N, CHAIN_ID, FORK)); + for (int i = 0; i < N; i++) { + final byte[] anchoredRow = PqV2Fixture.anchorPreimageRow(i); + kd.update(anchoredRow, 0, anchoredRow.length); + gen.append(',').append(PqV2Fixture.manifestEntry(i, N, CHAIN_ID, FORK)); + } + final byte[] anchoredHash = new byte[32]; + kd.doFinal(anchoredHash, 0); + gen.append("}},\"alloc\":{\"0000000000000000000000000000000000000fa1\":{\"storage\":{\"0x") + .append("0".repeat(64)) + .append("\":\"0x") + .append(Bytes.wrap(anchoredHash).toUnprefixedHexString()) + .append("\"}}}}"); + genesisPath = tmp.resolve("genesis-registry.json"); + Files.writeString(genesisPath, gen.toString()); + + System.setProperty("aere.falcon.validatorCount", Integer.toString(N)); + resetFalconSingleton(); + } + + @AfterEach + public void tearDown() throws Exception { + for (final String p : + new String[] { + "aere.falcon.manifest", + "aere.falcon.genesis", + "aere.falcon.anchor.address", + "aere.falcon.anchor.block", + "aere.falcon.attachBlock", + "aere.falcon.forkBlock", + "aere.falcon.validatorCount" + }) { + System.clearProperty(p); + } + resetFalconSingleton(); + } + + // ------------------------------------------------------------------------------------------- + // 1. The fixture's own control. + // ------------------------------------------------------------------------------------------- + + @Test + public void controlAWellFormedLateAnchorConfigurationStarts() { + lateAnchor(); + System.setProperty("aere.falcon.anchor.block", Long.toString(OBSERVE)); + System.setProperty("aere.falcon.attachBlock", Long.toString(ATTACH)); + System.setProperty("aere.falcon.forkBlock", Long.toString(FORK)); + + final FalconSealSupport pqc = FalconSealSupport.instance(); + assertThat(pqc.lateAnchorPending()) + .describedAs( + "the late-anchor manifest must load and stay PENDING, or every refusal below is a " + + "refusal about a registry that was never there") + .isTrue(); + assertThat(pqc.forkBlock()).isEqualTo(FORK); + assertThat(pqc.attachBlock()).isEqualTo(ATTACH); + assertThat(pqc.forkBlock()) + .describedAs( + "the ordering the guard exists to enforce, stated as a property: blocking arms strictly " + + "AFTER the height at which the registry it depends on can become active") + .isGreaterThan(OBSERVE); + } + + // ------------------------------------------------------------------------------------------- + // 2-3. Half one at config time. + // ------------------------------------------------------------------------------------------- + + @Test + public void aMalformedForkBlockRefusesToStart() { + lateAnchor(); + System.setProperty("aere.falcon.anchor.block", Long.toString(OBSERVE)); + System.setProperty("aere.falcon.attachBlock", Long.toString(ATTACH)); + // The exact typo shape a human makes when copying a height out of a document. + System.setProperty("aere.falcon.forkBlock", "9_189_161"); + + assertThatThrownBy(FalconSealSupport::instance) + .describedAs( + "a malformed blocking height must ABORT, never degrade to never-blocking with a log line") + .isInstanceOf(FalconSealSupport.ActivationConfigException.class) + .hasMessageContaining("MALFORMED"); + } + + @Test + public void aNegativeForkBlockRefusesToStart() { + lateAnchor(); + System.setProperty("aere.falcon.anchor.block", Long.toString(OBSERVE)); + System.setProperty("aere.falcon.attachBlock", Long.toString(ATTACH)); + System.setProperty("aere.falcon.forkBlock", "-1"); + + assertThatThrownBy(FalconSealSupport::instance) + .isInstanceOf(FalconSealSupport.ActivationConfigException.class) + .hasMessageContaining("negative"); + } + + // ------------------------------------------------------------------------------------------- + // 4. Half one where it actually survived: the value was validated but never OWNED. + // ------------------------------------------------------------------------------------------- + + @Test + public void theForkBlockIsResolvedOnceAndCannotBeReopenedAfterStartup() { + lateAnchor(); + System.setProperty("aere.falcon.anchor.block", Long.toString(OBSERVE)); + System.setProperty("aere.falcon.attachBlock", Long.toString(ATTACH)); + System.setProperty("aere.falcon.forkBlock", Long.toString(FORK)); + + final FalconSealSupport pqc = FalconSealSupport.instance(); + assertThat(pqc.forkBlock()).isEqualTo(FORK); + + // The startup guard has already run and passed. Nothing will run it again. If the accessor + // re-reads the property, then the ONE decision the whole PQC layer is gated on is a value that + // can still turn into "never blocking" at any moment, for any reason that leaves the property + // unparseable, and the only trace is one WARN line per call. + System.setProperty("aere.falcon.forkBlock", "not-a-number"); + assertThat(pqc.forkBlock()) + .describedAs( + "the blocking height must be resolved ONCE, at the boundary, and owned thereafter. A " + + "value that is validated at startup and re-parsed on every use is not validated.") + .isEqualTo(FORK); + } + + // ------------------------------------------------------------------------------------------- + // 5-7. Half two: the ORDER between the blocking height and the anchor observation height. + // ------------------------------------------------------------------------------------------- + + @Test + public void blockingOverAPendingAnchorWithNoDeclaredObservationHeightRefuses() { + lateAnchor(); + System.setProperty("aere.falcon.attachBlock", Long.toString(ATTACH)); + System.setProperty("aere.falcon.forkBlock", Long.toString(FORK)); + // aere.falcon.anchor.block deliberately NOT set. + + assertThatThrownBy(FalconSealSupport::instance) + .describedAs( + "with the registry still PENDING and no stated activation height, nothing in this " + + "process can compare the blocking height against the height at which the registry " + + "becomes usable, so the ordering error the finding names cannot be detected at all") + .isInstanceOf(FalconSealSupport.ActivationConfigException.class) + .hasMessageContaining("AERE-PQC-CFG-UNSAFE-06"); + } + + @Test + public void anAttachHeightBeforeTheObservationHeightRefuses() { + lateAnchor(); + System.setProperty("aere.falcon.anchor.block", Long.toString(ATTACH + 1L)); + System.setProperty("aere.falcon.attachBlock", Long.toString(ATTACH)); + System.setProperty("aere.falcon.forkBlock", Long.toString(FORK)); + + assertThatThrownBy(FalconSealSupport::instance) + .describedAs( + "attachment before the registry can be active emits nothing, so the log-only soak " + + "window measures nothing and the blocking height arrives over a registry no node " + + "has ever produced a seal against") + .isInstanceOf(FalconSealSupport.ActivationConfigException.class) + .hasMessageContaining("AERE-PQC-CFG-UNSAFE-07"); + } + + @Test + public void aForkHeightAtTheObservationHeightRefuses() { + lateAnchor(); + System.setProperty("aere.falcon.anchor.block", Long.toString(FORK)); + System.setProperty("aere.falcon.attachBlock", Long.toString(ATTACH)); + System.setProperty("aere.falcon.forkBlock", Long.toString(FORK)); + + assertThatThrownBy(FalconSealSupport::instance) + .describedAs("the literal stimulus in the finding: armed AT the anchor observation height") + .isInstanceOf(FalconSealSupport.ActivationConfigException.class) + .hasMessageContaining("AERE-PQC-CFG-UNSAFE-07"); + } + + // ------------------------------------------------------------------------------------------- + // 8-9. The new value must fail closed like every other one. + // ------------------------------------------------------------------------------------------- + + @Test + public void aMalformedObservationHeightRefuses() { + lateAnchor(); + System.setProperty("aere.falcon.anchor.block", "1e3"); + System.setProperty("aere.falcon.attachBlock", Long.toString(ATTACH)); + System.setProperty("aere.falcon.forkBlock", Long.toString(FORK)); + + assertThatThrownBy(FalconSealSupport::instance) + .isInstanceOf(FalconSealSupport.ActivationConfigException.class) + .hasMessageContaining("AERE-PQC-CFG-SYNTAX-09"); + } + + @Test + public void aNegativeObservationHeightRefuses() { + lateAnchor(); + System.setProperty("aere.falcon.anchor.block", "-5"); + System.setProperty("aere.falcon.attachBlock", Long.toString(ATTACH)); + System.setProperty("aere.falcon.forkBlock", Long.toString(FORK)); + + assertThatThrownBy(FalconSealSupport::instance) + .isInstanceOf(FalconSealSupport.ActivationConfigException.class) + .hasMessageContaining("AERE-PQC-CFG-SYNTAX-10"); + } + + // ------------------------------------------------------------------------------------------- + // 10-11. Scope controls. A guard that refuses everything is not a guard. + // ------------------------------------------------------------------------------------------- + + @Test + public void aGenesisAnchoredRegistryNeedsNoObservationHeight() { + System.setProperty("aere.falcon.genesis", genesisPath.toAbsolutePath().toString()); + System.setProperty("aere.falcon.attachBlock", Long.toString(ATTACH)); + System.setProperty("aere.falcon.forkBlock", Long.toString(FORK)); + // aere.falcon.anchor.block deliberately NOT set: a genesis-anchored registry is active from + // block 0, so there IS no observation height and demanding one would break the whole + // genesis-anchored deployment path. + + final FalconSealSupport pqc = FalconSealSupport.instance(); + assertThat(pqc.genesisAnchored()).isTrue(); + assertThat(pqc.addressBound()).isTrue(); + assertThat(pqc.forkBlock()).isEqualTo(FORK); + } + + @Test + public void anObservationHeightWithoutBlockingIsHarmless() { + lateAnchor(); + System.setProperty("aere.falcon.anchor.block", Long.toString(OBSERVE)); + // No forkBlock, no attachBlock: the log-only baseline every node on chain 2800 runs today. + + final FalconSealSupport pqc = FalconSealSupport.instance(); + assertThat(pqc.forkBlock()).isEqualTo(Long.MAX_VALUE); + assertThat(pqc.attachBlock()).isEqualTo(Long.MAX_VALUE); + assertThat(pqc.lateAnchorPending()).isTrue(); + } + + // ------------------------------------------------------------------------------------------- + // Helpers. + // ------------------------------------------------------------------------------------------- + + private void lateAnchor() { + System.setProperty("aere.falcon.manifest", manifestPath.toAbsolutePath().toString()); + System.setProperty("aere.falcon.anchor.address", ANCHOR_ADDRESS); + } + + private static void resetFalconSingleton() throws Exception { + final Field f = FalconSealSupport.class.getDeclaredField("instance"); + f.setAccessible(true); + f.set(null, null); + } +} diff --git a/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqForkThresholdReachabilityTest.java b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqForkThresholdReachabilityTest.java new file mode 100755 index 000000000..81bdb8bab --- /dev/null +++ b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqForkThresholdReachabilityTest.java @@ -0,0 +1,354 @@ +/* + * Copyright contributors to Besu / AERE Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + + +import java.lang.reflect.Field; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.apache.tuweni.bytes.Bytes; +import org.bouncycastle.crypto.digests.KeccakDigest; +import org.bouncycastle.pqc.crypto.falcon.FalconPrivateKeyParameters; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer; +import org.junit.jupiter.api.io.TempDir; + +/** + * D-078, THE HALF THAT WAS STILL OPEN: is the threshold K one the fleet can be GUARANTEED to meet? + * + *

The 2026-08-02 repair closed the mechanism that stopped the chain on one add-validator vote: it + * took the fleet-wide coverage question out of the per-commit attachment gate and made coverage a + * REPORT. That repair is correct and it is measured next door in {@code PqForkValidatorSetChangeTest}. + * But it left behind an explicit promise, written in the javadoc of {@code attachmentArmed}: + * + *

+ * + * "What coverage genuinely protects - that blocking is not ARMED over a partial manifest - is an + * arm-time decision, and it is made at arm time by armingReadinessDiagnostic() and by the operator". + * + *
+ * + *

MEASURED 2026-08-03: {@code armingReadinessDiagnostic()} checks exactly one thing, whether the + * manifest is ADDRESS-BOUND. It never reads the fleet size, never reads how many validators hold an + * anchored key, and never reads K. The arm-time decision the comment names did not exist, so the + * compensating control for the repair was a sentence. This class is what makes it exist. + * + *

THE ARITHMETIC, which is the whole finding and is not an opinion. A block needs {@code + * ceil(2N/3)} ECDSA committers, and Falcon seals ride on Commit messages, so the seals a proposer is + * GUARANTEED to hear are only those of the keyed validators it cannot avoid: {@code quorum - (N - + * keyed)}. The row that matters for this project: + * + *

+ *   N=7,  keyed 7, quorum 5 -> 5 guaranteed    K=5 reachable, margin exactly 0
+ *   N=9,  keyed 7, quorum 6 -> 4 guaranteed    K=5 NOT guaranteed
+ * 
+ * + *

The second row is the standing plan. "Grow to N=9 BEFORE arming" is right, and if the manifest + * is not re-anchored on the way there it produces a fleet that arms a threshold no proposer is + * guaranteed to meet. Before this guard a node in that state started, joined, armed, and the failure + * appeared later as a proposer that could not propose. That is the most expensive shape a + * configuration error can take, and it is the same shape the A8 repair already refused to allow for + * a non-address-bound manifest. + * + *

WHY AT CONFIG TIME AND NOWHERE ELSE. The lesson is borrowed, not invented: CometBFT applies a + * validator-set change only at H+2 and Ethereum's light-client protocol carries {@code + * next_sync_committee} a whole period ahead, both so that the set a cryptographic check runs over is + * known and comparable BEFORE the boundary rather than discovered at it. We cannot copy their + * mechanism, because at seven nodes under one operator there is no committee to sample. We can copy + * the discipline: DECLARE the fleet size, compare it against the threshold at config time, and + * refuse to cross the boundary if the comparison fails. The same reasoning already produced + * AERE-PQC-CFG-UNSAFE-04 and, for the fork height, AERE-PQC-CFG-UNSAFE-06/07 in D-079. + * + *

NOT MEASURED here, and named so it is not read as covered: what a LIVE fleet does in the rounds + * between the vote landing and the first proposer failing. That needs a network. This class measures + * the decision, which is the thing a node can be stopped from taking. + */ +// The D-078 label is our internal finding id. It names a fact about this +// code, not anything outside it. +public class PqForkThresholdReachabilityTest { + + /** Anchor activation height H. */ + private static final long H = 1_000L; + + /** The height from which the staged threshold is K. */ + private static final long K_AT = H + 10L; + + /** The threshold this project intends to arm. */ + private static final int K = 5; + + /** + * AERE D-146: the chain the registries this fixture writes are BOUND to. It is the same value + * {@link #armAnchor} states in {@code aere.pq.chainId}: a registry bound to one chain and an + * anchor armed on another is a configuration this fixture must never accidentally describe. + */ + private static final long CHAIN_ID = 2_800L; + + @TempDir private Path tmp; + + @BeforeEach + public void setUp() throws Exception { + resetFalconSingleton(); + } + + @AfterEach + public void tearDown() throws Exception { + for (final String p : + new String[] { + "aere.falcon.genesis", + "aere.falcon.key", + "aere.falcon.attachBlock", + "aere.falcon.validatorCount", + "aere.falcon.testnetAllowSmallFleet", + PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, + PqAnchorConfig.PROPERTY_MIN_SEALS, + PqAnchorConfig.PROPERTY_CHAIN_ID + }) { + System.clearProperty(p); + } + // Clearing the properties is NOT enough. PqAnchorProducer holds the configuration in a + // remembered field, and that field outlives the properties. Measured 2026-08-11: without the + // line below, this class leaves the anchor ARMED for whichever class runs next, and four tests + // in PqStartupHistoryTest fail on a guard that is firing CORRECTLY. The suite was green because + // the ordering of the day hid the leak, not because the leak was absent. + PqAnchorProducer.useConfigForTesting(null); + resetFalconSingleton(); + } + + // ------------------------------------------------------------------------------------------- + // 1. THE FINDING. A threshold the fleet is not guaranteed to meet must not start. + // ------------------------------------------------------------------------------------------- + + @Test + public void armingAThresholdTheFleetCannotGuaranteeMustRefuseToStart() throws Exception { + // The exact state the standing plan walks through: the set has grown to nine, the anchored + // manifest still names the original seven, and the threshold is the one the schedule arms. + writeAnchoredRegistry(7); + System.setProperty("aere.falcon.validatorCount", "9"); + armAnchor(K); + + assertThatThrownBy(FalconSealSupport::instance) + .describedAs( + "N=9 with 7 keyed guarantees only %d Falcon seal(s) among a block's committers, and the " + + "armed threshold is K=%d. A node must refuse to start rather than arm a threshold " + + "no proposer is guaranteed to be able to meet.", + FalconSealSupport.worstCaseKeyedSigners(9, 7), K) + .isInstanceOf(FalconSealSupport.ActivationConfigException.class) + .hasMessageContaining("AERE-PQC-CFG-UNSAFE-08") + // The message has to carry BOTH numbers. "Unsafe" without them sends an operator to read + // code; the two numbers are the whole diagnosis and the whole remedy. + .hasMessageContaining("K=" + K) + .hasMessageContaining("guaranteed"); + } + + // ------------------------------------------------------------------------------------------- + // 2. NEGATIVE CONTROL. A guard that refuses everything is not a guard. + // ------------------------------------------------------------------------------------------- + + @Test + public void aReachableThresholdMustStillStart() throws Exception { + // N=7 fully keyed: quorum 5, guaranteed 5, K=5. Margin is exactly zero, which is a different + // statement from "unreachable", and the guard must not confuse the two. This is also the + // configuration the fleet runs today, so a guard that refused it would be a self-inflicted halt. + writeAnchoredRegistry(7); + System.setProperty("aere.falcon.validatorCount", "7"); + armAnchor(K); + + assertThatCode(FalconSealSupport::instance) + .describedAs("N=7 fully keyed guarantees exactly K=%d; zero margin is not unreachable", K) + .doesNotThrowAnyException(); + assertThat(FalconSealSupport.instance().registrySize()).isEqualTo(7); + } + + @Test + public void growingTheManifestWithTheSetIsWhatMakesNineSafe() throws Exception { + // The remedy the refusal names, measured rather than asserted: re-anchor the manifest for the + // whole set and the same N=9, same K=5 starts. + writeAnchoredRegistry(9); + System.setProperty("aere.falcon.validatorCount", "9"); + armAnchor(K); + + assertThatCode(FalconSealSupport::instance).doesNotThrowAnyException(); + assertThat(FalconSealSupport.worstCaseKeyedSigners(9, 9)) + .describedAs("nine keyed of nine guarantees the full ECDSA quorum") + .isEqualTo(6); + } + + // ------------------------------------------------------------------------------------------- + // 3. INERT WHERE IT MUST BE INERT. Chain 2800 as it stands today. + // ------------------------------------------------------------------------------------------- + + @Test + public void withNoAnchorConfiguredTheGuardIsInert() throws Exception { + // aere.pq.anchorBlock is UNSET on the live chain, so K does not exist and there is nothing to + // compare. A guard that could stop a node in that state would be a new way to lose the fleet, + // which is a strictly worse defect than the one it repairs. + writeAnchoredRegistry(7); + System.setProperty("aere.falcon.validatorCount", "9"); + + assertThatCode(FalconSealSupport::instance) + .describedAs("no anchor configured: no threshold, no comparison, no refusal") + .doesNotThrowAnyException(); + } + + @Test + public void aScheduleThatNeverRaisesTheThresholdAboveZeroIsInert() throws Exception { + writeAnchoredRegistry(7); + System.setProperty("aere.falcon.validatorCount", "9"); + armAnchor(0); + + assertThatCode(FalconSealSupport::instance) + .describedAs("K=0 everywhere is the warm-up regime; nothing can fail to be met") + .doesNotThrowAnyException(); + } + + // ------------------------------------------------------------------------------------------- + // 4. THE CASE WITH NO KEYS AT ALL, which is the same arithmetic at its floor. + // ------------------------------------------------------------------------------------------- + + @Test + public void aPositiveThresholdWithNoAnchoredKeysMustRefuseToStart() throws Exception { + // No manifest anywhere and K=5: guaranteed is 0, so every block at or above H would be rejected + // for want of a certificate nobody can produce. Distinct from the A8 refusal, which only fires + // when aere.falcon.forkBlock is set; the anchor path has its own arming height. + System.setProperty("aere.falcon.validatorCount", "7"); + armAnchor(K); + + assertThatThrownBy(FalconSealSupport::instance) + .isInstanceOf(FalconSealSupport.ActivationConfigException.class) + .hasMessageContaining("AERE-PQC-CFG-UNSAFE-08"); + } + + // ------------------------------------------------------------------------------------------- + // 5. THE WAIVER IS EXPLICIT, NAMED, AND ONLY FOR ISOLATED NETWORKS. + // ------------------------------------------------------------------------------------------- + + @Test + public void anIsolatedTestNetworkCanWaiveTheGuardExplicitly() throws Exception { + writeAnchoredRegistry(7); + System.setProperty("aere.falcon.validatorCount", "9"); + System.setProperty("aere.falcon.testnetAllowSmallFleet", "true"); + armAnchor(K); + + assertThatCode(FalconSealSupport::instance) + .describedAs( + "the same switch that waives the N>=9 rule waives this one, because both say the same " + + "thing: this fleet has no Falcon fault margin and must not be a mainnet") + .doesNotThrowAnyException(); + } + + // ------------------------------------------------------------------------------------------- + // 6. THE ARITHMETIC ITSELF, at the boundary, as a pure function. + // ------------------------------------------------------------------------------------------- + + @Test + public void theDeficitIsTheDistanceBetweenTheThresholdAndTheGuarantee() { + assertThat(FalconSealSupport.thresholdDeficit(7, 7, 5)) + .describedAs("N=7 fully keyed meets K=5 exactly") + .isZero(); + assertThat(FalconSealSupport.thresholdDeficit(8, 7, 5)) + .describedAs("one unkeyed validator added: still met") + .isZero(); + assertThat(FalconSealSupport.thresholdDeficit(9, 7, 5)) + .describedAs("two added without re-anchoring: short by one, which is the halt") + .isEqualTo(1); + assertThat(FalconSealSupport.thresholdDeficit(9, 9, 5)).isZero(); + assertThat(FalconSealSupport.thresholdDeficit(7, 0, 1)) + .describedAs("no keys at all: a positive threshold is short by all of it") + .isEqualTo(1); + assertThat(FalconSealSupport.thresholdDeficit(7, 7, 0)) + .describedAs("K=0 can never be in deficit") + .isZero(); + } + + // ------------------------------------------------------------------------------------------- + // Helpers. + // ------------------------------------------------------------------------------------------- + + /** Arm the V2 anchor from system configuration with a staged threshold that reaches {@code k}. */ + private static void armAnchor(final int k) { + System.setProperty(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, Long.toString(H)); + // AERE CONFIGURATIE-STRICTA (2026-08-06): an activation height without an explicit + // chain id is now a startup refusal, because a silently defaulted 0 in the D and M + // pre-images is the Holesky shape. The fixture states what the fleet states. + System.setProperty(PqAnchorConfig.PROPERTY_CHAIN_ID, Long.toString(CHAIN_ID)); + System.setProperty( + PqAnchorConfig.PROPERTY_MIN_SEALS, H + ":0," + K_AT + ":" + k); + } + + /** + * Write a genesis-anchored, address-bound Falcon manifest for {@code count} validators and point + * this node at index 0's key, exactly as {@code PqForkValidatorSetChangeTest} does. The anchored hash + * is accumulated in lockstep with the manifest text, so the fixture is anchored the way a real + * genesis is rather than by a flag. + */ + private void writeAnchoredRegistry(final int count) throws Exception { + // AERE D-146 (2026-08-06): v2, proof-bound, bound at H, the height armAnchor() arms from. The + // rows come from PqV2Fixture because a v2 claim must be signed by the validator whose address + // is on the row, and the 0xA00+i addresses this used to spell have no key behind them. + final KeccakDigest kd = new KeccakDigest(256); + final StringBuilder manifest = new StringBuilder(); + manifest + .append("{\"config\":{\"aereFalconRegistry\":{") + .append(PqV2Fixture.manifestHeader(count, CHAIN_ID, H)); + for (int i = 0; i < count; i++) { + final FalconPrivateKeyParameters priv = PqV2Fixture.privateKey(i); + final byte[] anchoredRow = PqV2Fixture.anchorPreimageRow(i); + kd.update(anchoredRow, 0, anchoredRow.length); + manifest.append(',').append(PqV2Fixture.manifestEntry(i, count, CHAIN_ID, H)); + if (i == 0) { + final Path key0 = tmp.resolve("falcon-key-0.properties"); + Files.writeString( + key0, + "index=0\n" + + "f=" + + Bytes.wrap(priv.getSpolyf()).toHexString() + + "\n" + + "g=" + + Bytes.wrap(priv.getG()).toHexString() + + "\n" + + "F=" + + Bytes.wrap(priv.getSpolyF()).toHexString() + + "\n" + + "pk=" + + Bytes.wrap(PqV2Fixture.publicKey(i)).toHexString() + + "\n"); + System.setProperty("aere.falcon.key", key0.toAbsolutePath().toString()); + } + } + manifest.append("}},\"alloc\":{\"0000000000000000000000000000000000000fa1\":{\"storage\":{\"0x") + .append("0".repeat(64)) + .append("\":\"0x"); + final byte[] anchoredHash = new byte[32]; + kd.doFinal(anchoredHash, 0); + manifest.append(Bytes.wrap(anchoredHash).toUnprefixedHexString()).append("\"}}}}"); + + final Path genesis = tmp.resolve("genesis-registry.json"); + Files.writeString(genesis, manifest.toString()); + System.setProperty("aere.falcon.genesis", genesis.toAbsolutePath().toString()); + } + + private static void resetFalconSingleton() throws Exception { + final Field f = FalconSealSupport.class.getDeclaredField("instance"); + f.setAccessible(true); + f.set(null, null); + } +} diff --git a/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqForkValidatorSetChangeTest.java b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqForkValidatorSetChangeTest.java new file mode 100755 index 000000000..724650d85 --- /dev/null +++ b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqForkValidatorSetChangeTest.java @@ -0,0 +1,430 @@ +/* + * Copyright contributors to Besu / AERE Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.withSettings; + +import org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer; +import org.hyperledger.besu.consensus.common.validator.ValidatorProvider; +import org.hyperledger.besu.datatypes.Address; +import org.hyperledger.besu.ethereum.ProtocolContext; +import org.hyperledger.besu.ethereum.core.BlockHeader; +import org.hyperledger.besu.ethereum.core.BlockHeaderTestFixture; + +import java.lang.reflect.Field; +import java.nio.file.Files; +import java.nio.file.Path; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.OptionalInt; + +import org.apache.tuweni.bytes.Bytes; +import org.apache.tuweni.bytes.Bytes32; +import org.bouncycastle.crypto.digests.KeccakDigest; +import org.bouncycastle.pqc.crypto.falcon.FalconPrivateKeyParameters; +import org.bouncycastle.pqc.crypto.falcon.FalconSigner; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.quality.Strictness; + +/** + * D-078. THE MEASUREMENT THAT DID NOT EXIST. + * + *

The registry entry reads: "if PQC were armed, an ordinary add-validator vote would stop the + * chain: the Falcon blocking quorum follows the dynamic set and cannot be reached inside the vote + * window", and it carried {@code verifica: NICIUNA} because "the direct measurement would require + * ARMING PQC on a chain, which is exactly the thing that stops the chain". + * + *

That is true of a whole chain. It is NOT true of the decision that stops it. Every step from + * "the validator set changed" to "no block can be proposed" is taken by three objects in this + * module, each of which is a pure function of its inputs: {@link FalconSealSupport#attachmentArmed} + * decides whether this node emits a Falcon seal at all, {@link PqSealCache} holds what was heard, + * and {@link PqAnchorProducer#apply} decides whether this node may propose. This class drives those + * three with a REAL address-bound genesis-anchored registry and REAL Falcon-512 keys, and asks the + * question the registry says cannot be asked. + * + *

WHAT EACH TEST MEASURES, and why each of them can fail: + * + *

    + *
  1. {@link #baselineTheGateIsArmedWhileTheRegistryCoversTheSet()} - the negative control for + * every other test here. If the gate were simply always off, or the registry never loaded, + * the three tests below would "pass" for a reason that has nothing to do with D-078. This one + * fails if the fixture is not genuinely armed. + *
  2. {@link #addingOneValidatorMustNotTurnSealAttachmentOff()} - D-078 itself, on the exact + * stimulus in the title: one more validator in the set, with no Falcon key. + *
  3. {@link #aNodeStartedAboveTheAnchorHeightMustStillAttach()} - the SAME halt through a much + * more ordinary door than a vote: a restart. Above the anchor height the only caller of + * {@code observeValidators} has retired, so a node that starts there never observes a + * validator set at all. + *
  4. {@link #theProposerRefusesWhenNothingWasAttachedAndProposesWhenSomethingWas()} - the causal + * link, measured in both directions, so that "attachment off" to "chain stopped" is not an + * assertion. Nothing heard: the proposer throws and cannot propose. Five real seals heard: + * the proposer produces extraData carrying a five-seal certificate. + *
+ * + *

NOT MEASURED here, deliberately, and named so it is not mistaken for covered: how many rounds a + * live fleet takes to stop once every proposer refuses, and what a syncing node does meanwhile. + * Those need a network, and the network run is separate evidence. + */ +// The D-078 label is our internal finding id. It names a fact about this +// code, not anything outside it. +public class PqForkValidatorSetChangeTest { + + /** Anchor activation height H used throughout. */ + private static final long H = 1_000L; + + /** Seal-attachment height, comfortably below H. */ + private static final long ATTACH = 900L; + + /** Height from which the staged threshold K is 5, i.e. the armed regime. */ + private static final long K_AT = H + 10L; + + private static final int K = 5; + + private static final int N = 7; + + private static final long CHAIN_ID = 220_878L; + + @TempDir private Path tmp; + + private final List

keyedValidators = new ArrayList<>(); + private final List privateKeys = new ArrayList<>(); + private Address newcomer; + private Path genesisPath; + private Path key0Path; + + @BeforeEach + public void setUp() throws Exception { + // The genesis-anchored path is only satisfied when keccak256(addr20 || pk, indices ascending) + // equals the hash stored in the genesis alloc. The digest is accumulated here in lockstep with + // the manifest text, so the fixture is anchored the same way a real genesis is. + // + // AERE D-146 (2026-08-06): v2, proof-bound, bound at H. The addresses come from PqV2Fixture and + // are DERIVED from real secp256k1 keys, because a claim has to be signed by the validator whose + // address is on the row and no key produces the 0xA00+i addresses this used to spell. + final KeccakDigest kd = new KeccakDigest(256); + final StringBuilder manifest = new StringBuilder(); + manifest + .append("{\"config\":{\"aereFalconRegistry\":{") + .append(PqV2Fixture.manifestHeader(N, CHAIN_ID, H)); + for (int i = 0; i < N; i++) { + final FalconPrivateKeyParameters priv = PqV2Fixture.privateKey(i); + privateKeys.add(priv); + keyedValidators.add(PqV2Fixture.address(i)); + final byte[] anchoredRow = PqV2Fixture.anchorPreimageRow(i); + kd.update(anchoredRow, 0, anchoredRow.length); + manifest.append(',').append(PqV2Fixture.manifestEntry(i, N, CHAIN_ID, H)); + if (i == 0) { + key0Path = tmp.resolve("falcon-key-0.properties"); + Files.writeString( + key0Path, + "index=0\n" + + "f=" + + Bytes.wrap(priv.getSpolyf()).toHexString() + + "\n" + + "g=" + + Bytes.wrap(priv.getG()).toHexString() + + "\n" + + "F=" + + Bytes.wrap(priv.getSpolyF()).toHexString() + + "\n" + + "pk=" + + Bytes.wrap(PqV2Fixture.publicKey(i)).toHexString() + + "\n"); + System.setProperty("aere.falcon.key", key0Path.toAbsolutePath().toString()); + } + } + manifest.append("}},\"alloc\":{\"0000000000000000000000000000000000000fa1\":{\"storage\":{\"0x") + .append("0".repeat(64)) + .append("\":\"0x"); + final byte[] anchoredHash = new byte[32]; + kd.doFinal(anchoredHash, 0); + manifest.append(Bytes.wrap(anchoredHash).toUnprefixedHexString()).append("\"}}}}"); + // The eighth validator: a perfectly ordinary node that an ordinary vote admits, and that has no + // Falcon key because the manifest that is anchored on chain was written for seven. It is row N + // of the same probe pool, so it is a REAL address with a REAL key behind it that simply was not + // filed in the registry - which is the situation this test is about. + newcomer = PqV2Fixture.address(N); + + genesisPath = tmp.resolve("genesis-registry.json"); + Files.writeString(genesisPath, manifest.toString()); + System.setProperty("aere.falcon.genesis", genesisPath.toAbsolutePath().toString()); + System.setProperty("aere.falcon.attachBlock", Long.toString(ATTACH)); + + resetFalconSingleton(); + PqSealCache.instance().clear(); + PqAnchorProducer.useConfigForTesting( + new PqAnchorConfig(CHAIN_ID, H, Map.of(H, 0, K_AT, K), OptionalInt.empty(), false)); + } + + @AfterEach + public void tearDown() throws Exception { + System.clearProperty("aere.falcon.genesis"); + System.clearProperty("aere.falcon.key"); + System.clearProperty("aere.falcon.attachBlock"); + resetFalconSingleton(); + PqSealCache.instance().clear(); + PqAnchorProducer.useConfigForTesting(null); + } + + // ----------------------------------------------------------------------------------------- + // 1. Negative control for the fixture itself. + // ----------------------------------------------------------------------------------------- + + @Test + public void baselineTheGateIsArmedWhileTheRegistryCoversTheSet() { + final FalconSealSupport pqc = FalconSealSupport.instance(); + assertThat(pqc.genesisAnchored()) + .describedAs("the fixture must load a GENESIS-ANCHORED registry, or nothing below means anything") + .isTrue(); + assertThat(pqc.addressBound()).isTrue(); + assertThat(pqc.registrySize()).isEqualTo(N); + assertThat(pqc.signingEnabled()).isTrue(); + + pqc.observeValidators(H + 1L, keyedValidators); + assertThat(pqc.attachmentArmed(H + 2L)) + .describedAs("with the registry covering all %d validators the gate must be ARMED", N) + .isTrue(); + final Optional seal = pqc.sign(H + 2L, message(H + 1L)); + assertThat(seal).isPresent(); + assertThat(pqc.verify(0, message(H + 1L), seal.get().getSignature())).isTrue(); + } + + // ----------------------------------------------------------------------------------------- + // 2. D-078 on its own stimulus: one validator added. + // ----------------------------------------------------------------------------------------- + + @Test + public void addingOneValidatorMustNotTurnSealAttachmentOff() { + final FalconSealSupport pqc = FalconSealSupport.instance(); + + pqc.observeValidators(H + 1L, keyedValidators); + assertThat(pqc.attachmentArmed(H + 2L)) + .describedAs("armed before the set changes") + .isTrue(); + + final List
afterVote = new ArrayList<>(keyedValidators); + afterVote.add(newcomer); + pqc.observeValidators(H + 2L, afterVote); + + assertThat(pqc.attachmentArmed(H + 3L)) + .describedAs( + "D-078: one ordinary add-validator vote must not switch Falcon seal ATTACHMENT off. " + + "It is a fleet-wide fact, so it turns off on EVERY node at the same height; with " + + "no node attaching, no proposer can gather K=%d seals and the chain stops with no " + + "way to carry the re-anchoring transaction that would repair it.", + K) + .isTrue(); + assertThat(pqc.sign(H + 3L, message(H + 2L))) + .describedAs("and the seal must actually be produced, not merely permitted") + .isPresent(); + } + + // ----------------------------------------------------------------------------------------- + // 3. The same halt through a restart, which needs no vote at all. + // ----------------------------------------------------------------------------------------- + + @Test + public void aNodeStartedAboveTheAnchorHeightMustStillAttach() { + // No observeValidators call at all. Above H the only caller of it, FalconSealValidationRule, + // returns at its retirement gate before observing, so this is exactly the state of a node whose + // chain head is already above H when the process starts. + final FalconSealSupport pqc = FalconSealSupport.instance(); + assertThat(pqc.attachmentArmed(H + 50L)) + .describedAs( + "a node that starts above the anchor height has observed no validator set, and " + + "\"I could not measure the set\" must not be answered with \"stop signing\": that " + + "answer is the halt. Restarting a node is an ordinary operation.") + .isTrue(); + assertThat(pqc.sign(H + 50L, message(H + 49L))).isPresent(); + } + + // ----------------------------------------------------------------------------------------- + // 4. The causal link, measured in BOTH directions. + // ----------------------------------------------------------------------------------------- + + @Test + public void theProposerRefusesWhenNothingWasAttachedAndProposesWhenSomethingWas() { + final BlockHeader parent = new BlockHeaderTestFixture().number(K_AT + 20L).buildHeader(); + final ProtocolContext context = contextWith(keyedValidators); + final BftExtraData base = + new BftExtraData( + Bytes32.ZERO, + Collections.emptyList(), + Optional.empty(), + 0, + keyedValidators, + Collections.emptyList()); + + // (a) nothing heard, because nothing was attached: the proposer cannot propose. + assertThatThrownBy(() -> PqAnchorProducer.apply(base, parent, context)) + .isInstanceOf(PqAnchorNotReadyException.class); + + // (b) five real Falcon seals heard: the same proposer, same inputs, produces a certificate. + // Without this half, (a) would be satisfied by a producer that always refuses. + final Bytes32 m = + PqAnchor.commitMessage(CHAIN_ID, parent.getNumber(), parent.getHash().getBytes()); + final List heard = new ArrayList<>(); + for (int i = 0; i < K; i++) { + heard.add(new FalconSeal(i, Bytes.wrap(falconSign(privateKeys.get(i), m)))); + } + PqSealCache.instance().record(parent.getNumber(), parent.getHash(), heard); + + final BftExtraData produced = PqAnchorProducer.apply(base, parent, context); + assertThat(produced.getFalconSeals()).hasSize(K); + assertThat(produced.getVanityData()) + .isEqualTo( + PqAnchor.anchorDigest( + CHAIN_ID, + parent.getNumber(), + parent.getHash().getBytes(), + PqAnchor.sortedByIndex(heard))); + } + + // ----------------------------------------------------------------------------------------- + // 4b. What an unkeyed validator actually costs, as a number rather than as a worry. + // ----------------------------------------------------------------------------------------- + + /** + * Removing coverage from the attachment gate stops the halt; it does not make adding an unkeyed + * validator free. The price is the guaranteed number of anchored-key holders among a block's ECDSA + * committers, and it is arithmetic, not opinion: a block needs {@code ceil(2N/3)} committers, and + * the unluckiest committer set takes every unkeyed validator first. + * + *

The three rows below are the ones that decide the project's own arming order, so they are + * measured here rather than reasoned about in a document: + * + *

+   *   N=7, keyed 7, quorum 5 -> 5 guaranteed   K=5 is met, with EXACTLY zero margin
+   *   N=8, keyed 7, quorum 6 -> 5 guaranteed   K=5 is still met, still zero margin
+   *   N=9, keyed 7, quorum 6 -> 4 guaranteed   K=5 is NOT guaranteed any more
+   * 
+ * + *

Read against the standing rule "grow to N=9 BEFORE arming", that third row is the warning: + * growing to nine while the anchored manifest still names seven is exactly the state in which a + * proposer can legitimately fail to assemble a certificate. The manifest has to grow with the set. + */ + @Test + public void theCostOfAnUnkeyedValidatorIsANumberAndTheNumberIsThis() { + assertThat(FalconSealSupport.worstCaseKeyedSigners(7, 7)) + .describedAs("N=7 fully keyed: K=5 is met with zero margin") + .isEqualTo(5); + assertThat(FalconSealSupport.worstCaseKeyedSigners(8, 7)) + .describedAs("one validator added without re-anchoring: K=5 still met, still zero margin") + .isEqualTo(5); + assertThat(FalconSealSupport.worstCaseKeyedSigners(9, 7)) + .describedAs( + "two added without re-anchoring: below K=5, so a proposer can legitimately fail. This " + + "is the row that constrains growing to N=9 before arming.") + .isEqualTo(4); + assertThat(FalconSealSupport.worstCaseKeyedSigners(7, 0)).isZero(); + assertThat(FalconSealSupport.worstCaseKeyedSigners(0, 0)).isZero(); + } + + // ----------------------------------------------------------------------------------------- + // 5. NEGATIVE CONTROL for this whole file: the gate must still refuse what it must refuse. + // ----------------------------------------------------------------------------------------- + + /** + * Every other test here asserts that the gate says YES. Replace {@code attachmentArmed} with + * {@code return true} and all of them still pass, which would make this file a proof that cannot + * fail. These four assertions are what makes that substitution impossible: each names a condition + * the D-078 repair deliberately did NOT touch. + * + * @throws Exception if the fixture cannot be rebuilt + */ + @Test + public void theGateStillRefusesEverythingItMustStillRefuse() throws Exception { + // (1) below the configured attachment height. + assertThat(FalconSealSupport.instance().attachmentArmed(ATTACH - 1L)) + .describedAs("below the attachment height nothing may be attached") + .isFalse(); + + // (2) no attachment height configured at all, which is the default and the state of chain 2800. + System.clearProperty("aere.falcon.attachBlock"); + resetFalconSingleton(); + assertThat(FalconSealSupport.instance().attachmentArmed(H + 5L)) + .describedAs("with aere.falcon.attachBlock unset a node holding a key attaches nothing") + .isFalse(); + + // (3) attachment height reached, but no anchored registry to be checked against. + System.setProperty("aere.falcon.attachBlock", Long.toString(ATTACH)); + System.clearProperty("aere.falcon.genesis"); + resetFalconSingleton(); + assertThat(FalconSealSupport.instance().attachmentArmed(H + 5L)) + .describedAs("a seal is never emitted against a registry that cannot be checked") + .isFalse(); + + // (4) anchored, address-bound registry, but it does not bind THIS node's index. The seal would + // be unattributable, so the seals rule would refuse the whole header carrying it. + System.setProperty("aere.falcon.genesis", genesisPath.toAbsolutePath().toString()); + final Path strayKey = tmp.resolve("falcon-key-stray.properties"); + Files.writeString(strayKey, Files.readString(key0Path).replace("index=0", "index=42")); + System.setProperty("aere.falcon.key", strayKey.toAbsolutePath().toString()); + resetFalconSingleton(); + final FalconSealSupport stray = FalconSealSupport.instance(); + assertThat(stray.genesisAnchored()) + .describedAs("the registry must still load, or (4) would pass for the wrong reason") + .isTrue(); + assertThat(stray.attachmentArmed(H + 5L)) + .describedAs("an index the anchored registry does not bind must not attach") + .isFalse(); + assertThat(stray.sign(H + 5L, message(H + 4L))).isEmpty(); + } + + // ----------------------------------------------------------------------------------------- + // Helpers. + // ----------------------------------------------------------------------------------------- + + private static Bytes32 message(final long blockNumber) { + return PqAnchor.commitMessage(CHAIN_ID, blockNumber, Bytes32.leftPad(Bytes.of(1))); + } + + private static byte[] falconSign(final FalconPrivateKeyParameters key, final Bytes32 m) { + final FalconSigner signer = new FalconSigner(); + signer.init(true, key); + return signer.generateSignature(m.toArray()); + } + + private static ProtocolContext contextWith(final Collection

validators) { + final ValidatorProvider validatorProvider = + mock(ValidatorProvider.class, withSettings().strictness(Strictness.LENIENT)); + when(validatorProvider.getValidatorsForBlock(any())).thenReturn(validators); + when(validatorProvider.getValidatorsAfterBlock(any())).thenReturn(validators); + final BftContext bftContext = + mock(BftContext.class, withSettings().strictness(Strictness.LENIENT)); + when(bftContext.getValidatorProvider()).thenReturn(validatorProvider); + when(bftContext.as(any())).thenReturn(bftContext); + return new ProtocolContext.Builder().withConsensusContext(bftContext).build(); + } + + private static void resetFalconSingleton() throws Exception { + final Field f = FalconSealSupport.class.getDeclaredField("instance"); + f.setAccessible(true); + f.set(null, null); + } +} diff --git a/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqInertBinaryTest.java b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqInertBinaryTest.java new file mode 100755 index 000000000..80a2cefe9 --- /dev/null +++ b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqInertBinaryTest.java @@ -0,0 +1,475 @@ +/* + * Copyright contributors to Besu / AERE Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.withSettings; + +import org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer; +import org.hyperledger.besu.consensus.common.validator.ValidatorProvider; +import org.hyperledger.besu.datatypes.Address; +import org.hyperledger.besu.ethereum.ProtocolContext; +import org.hyperledger.besu.ethereum.core.BlockHeader; +import org.hyperledger.besu.ethereum.core.BlockHeaderTestFixture; + +import java.lang.reflect.Field; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.Properties; +import java.util.stream.Collectors; + +import org.apache.logging.log4j.Level; +import org.apache.tuweni.bytes.Bytes32; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.quality.Strictness; + +/** + * THE COMPATIBILITY PROPERTY, which is the one that decides whether any of this can be shipped. + * + *

The three anchor patches plus the D-146 arming gate are meant to travel onto the seven live + * boxes BEFORE the activation height, so that the fleet is already running the binary when the + * height arrives and activation is a restart-free event. That plan is only sound if a node holding + * this binary and NO {@code aere.pq.*} configuration is indistinguishable from one holding the + * binary it replaces: it must start, it must produce blocks, and it must not say a word about an + * anchor that is not armed. If that property is lost, the whole package is unusable regardless of + * how correct the anchor logic is, because it could not be staged. + * + *

WHY THE SILENCE IS MEASURED AND NOT ASSUMED. "It returns early, so it cannot log" is a reading + * of the code, not a measurement, and the integrated tree has four patches whose log sites nobody + * has looked at together. Here the actual Log4j2 pipeline is tapped and the lines are counted. + * + *

WHY {@link #positiveControlTheCaptorSEESTheAnchorWhenItISArmed} is not optional. A captor that + * attaches to nothing reports silence forever, and every assertion in {@link + * #withNoAerePropertiesTheProposerProducesABlockAndSaysNOTHING} would pass against a broken tap. + * The positive control arms the anchor and requires that the SAME captor, in the same JVM, sees the + * producer's activation line. Without it this class would be a proof that cannot go red. + * + *

WHY THE CAPTOR IS BUILT BY REFLECTION. {@code log4j-core}, which owns the appender API, is on + * this module's RUNTIME test classpath but not its COMPILE one - measured, not assumed. Adding it as + * a compile dependency would put a build file into the AERE overlay, which until now is Java only. + * Reflection keeps the overlay unchanged, and the positive control is what makes it safe: if any of + * the reflective steps silently failed, the captor would see nothing and the positive control would + * be the test that fails. + * + *

NOT MEASURED, and written rather than implied: nothing is deployed and no node is started. That + * an unarmed node on one of the seven real boxes behaves this way over a real chain, at 523 ms + * blocks, alongside a peer that IS armed, is NOT MEASURED and needs the rehearsal network. + */ +public class PqInertBinaryTest { + + /** + * Loggers that exist ONLY because of the anchor work, so any line from them on an unarmed node is + * by itself a finding. + * + *

{@code FalconSealSupport} is deliberately NOT here even though it is the loudest of them. + * It predates the anchor and legitimately says one thing at startup; listing it would make the + * filter report a four-year-old INFO line as new anchor chatter. Its armed messages are caught by + * {@link #ANCHOR_WORDS} instead, which keys on what the line SAYS rather than who said it. + */ + private static final List ANCHOR_LOGGERS = + List.of( + "org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer", + "org.hyperledger.besu.consensus.common.bft.PqAnchorConfig", + "org.hyperledger.besu.consensus.common.bft.PqSealStore", + "org.hyperledger.besu.consensus.common.bft.PqRegistryBinding"); + + /** + * Words that name the ANCHOR - the capability these patches add - in a message body, whichever + * logger emitted it. Deliberately narrower than "anything mentioning Falcon": the Falcon registry + * predates all of this, and a filter that cannot tell the new surface from the old one would call + * a pre-existing line a regression. + */ + private static final List ANCHOR_WORDS = + List.of("pq-anchor", "anchor", "aere-pqc", "aere pqc d2"); + + /** + * THE ONE LINE an unarmed node has always written, quoted so that any NEW startup chatter turns + * this class red. + * + *

MEASURED 2026-08-06, and it is the reason this constant exists rather than an {@code + * isEmpty()} on everything. The first shape of this test asserted total silence and went red on + * this line. It is not a regression: {@code git log -S} places it in commit 307fd0d0, the snapshot + * of everything built between 14 June and 2 August, so it predates all three anchor patches and + * the arming gate. It is {@code LOG.info} and it says the node has no Falcon registry, which is + * true and was equally true of the binary being replaced. + * + *

So the property that is actually worth defending is not "says nothing" - that was never true + * - but "says nothing NEW, and nothing about the anchor". Pinning the exact text is what makes the + * second half enforceable: a fourth patch that adds one more startup line has to come here and + * change this constant deliberately. + */ + private static final String THE_ONE_PRE_EXISTING_LINE = + "AERE PQC: no Falcon registry configured " + + "(aere.falcon.genesis/aere.falcon.manifest/aere.falcon.registry); " + + "hybrid seal verification will be a no-op."; + + private static final long CHAIN_ID = 220_878L; + + private static final long H = 4_000L; + + /** + * Every property this class may touch. The unarmed test does not rely on this list - it sweeps the + * whole property table - but the armed one must put back exactly what it took. + */ + private static final List OWNED_PROPERTIES = + List.of( + "aere.falcon.registry", + "aere.falcon.forkBlock", + "aere.falcon.attachBlock", + "aere.falcon.validatorCount", + "aere.falcon.testnetAllowSmallFleet", + PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, + PqAnchorConfig.PROPERTY_CHAIN_ID, + PqAnchorConfig.PROPERTY_MIN_SEALS); + + @BeforeEach + public void setUp() throws Exception { + clearOwnedProperties(); + forgetAnchorConfig(); + resetFalconSingleton(); + } + + @AfterEach + public void tearDown() throws Exception { + clearOwnedProperties(); + forgetAnchorConfig(); + resetFalconSingleton(); + } + + // --------------------------------------------------------------------------------------------- + // THE PROPERTY. + // --------------------------------------------------------------------------------------------- + + /** + * A node carrying the integrated binary and no anchor configuration starts, produces a block, and + * logs nothing about the anchor. + * + *

The block-production half is asserted on OBJECT IDENTITY, not equality. {@code + * PqAnchorProducer.apply} returns its argument unchanged at the first branch when the anchor is + * not active; an equal-but-rebuilt {@code BftExtraData} would mean the producer had walked the + * certificate path and merely arrived back at the same value, which is a different and much + * weaker statement. + */ + @Test + public void withNoAerePropertiesTheProposerProducesABlockAndSaysNOTHING() throws Exception { + // The precondition is MEASURED over the whole property table rather than trusted to the + // teardown of whatever test ran before this one in this JVM. + assertThat(systemPropertiesStartingWith("aere.")) + .describedAs("the precondition of this test, measured rather than assumed") + .isEmpty(); + + final LogCaptor captor = LogCaptor.attach(); + final BftExtraData produced; + final BftExtraData base = plainExtraData(); + try { + assertThatCode(FalconSealSupport::instance) + .describedAs("a box with no key ceremony behind it must still come up") + .doesNotThrowAnyException(); + + final BlockHeader parent = new BlockHeaderTestFixture().number(H + 500L).buildHeader(); + produced = PqAnchorProducer.apply(base, parent, contextWith(List.of())); + } finally { + captor.detach(); + } + + assertThat(produced) + .describedAs( + "the unarmed producer must hand back the very object it was given; an equal copy would " + + "mean it had walked the certificate path") + .isSameAs(base); + + assertThat(PqAnchorProducer.config().everActive()) + .describedAs("and it must consider itself never-active, not merely inactive right now") + .isFalse(); + + assertThat(captor.anchorLines()) + .describedAs( + "an operator staging this binary before the height must see NOTHING about the anchor; " + + "%d line(s) in total were seen, so the captor was live", + captor.total()) + .isEmpty(); + + // And nothing NEW of any kind. This is the half that catches a future patch adding chatter. + assertThat(captor.aereLines()) + .describedAs( + "the whole AERE output of an unarmed node, pinned: exactly the one INFO line that " + + "predates these patches (commit 307fd0d0). A new line here is a staging " + + "regression even when it is harmless, because it changes what the fleet prints " + + "on a restart that is supposed to be a no-op.") + .containsExactly(THE_ONE_PRE_EXISTING_LINE); + } + + // --------------------------------------------------------------------------------------------- + // THE POSITIVE CONTROL, without which the test above proves nothing. + // --------------------------------------------------------------------------------------------- + + /** + * The same captor, the same JVM, the same loggers - with the anchor armed. If this does not see a + * line, the silence measured above is the silence of a broken tap and means nothing. + * + *

The line chosen is the producer's own activation notice, emitted from {@code + * PqAnchorProducer.config()} the first time a configuration is built. Its once-per-JVM latch is + * reset by {@code useConfigForTesting(null)}, which is why {@link #forgetAnchorConfig()} runs + * before every test in this class. + */ + @Test + public void positiveControlTheCaptorSEESTheAnchorWhenItISArmed() throws Exception { + System.setProperty(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, Long.toString(H)); + System.setProperty(PqAnchorConfig.PROPERTY_CHAIN_ID, Long.toString(CHAIN_ID)); + // THIS WAS `H + ":0"` UNTIL 2026-08-07, and the threshold floor added the same day refused it. + // Refused correctly, and it is worth reading twice: this positive control only needed "an + // armed anchor", so it had used the shortest form that got past the loader, and that form + // was exactly the TOOTHLESS one, a schedule whose threshold is zero for ever. In other + // words our own test property had picked, without meaning to, precisely the dangerous + // configuration, and would have passed it on as "this is how the anchor is armed" to anyone + // who copied from here. + // + // The warm-up window is kept, because that is the form the activation plan recommends, and + // the step that gives the schedule teeth is added. The positive control measures the same + // thing: the captor hears the producer's arming line. + System.setProperty(PqAnchorConfig.PROPERTY_MIN_SEALS, H + ":0," + (H + 1000L) + ":3"); + + final LogCaptor captor = LogCaptor.attach(); + try { + PqAnchorProducer.config(); + } finally { + captor.detach(); + } + + assertThat(captor.anchorLines()) + .describedAs( + "the captor must be able to hear the anchor, or the silence next door is worthless") + .isNotEmpty(); + assertThat(String.join("\n", captor.anchorLines())).contains("producer armed"); + } + + // --------------------------------------------------------------------------------------------- + // Helpers. + // --------------------------------------------------------------------------------------------- + + /** Extra data with no anchor digest, i.e. exactly what a pre-fork proposer builds. */ + private static BftExtraData plainExtraData() { + return new BftExtraData( + Bytes32.ZERO, + Collections.emptyList(), + Optional.empty(), + 0, + Collections.emptyList(), + Collections.emptyList()); + } + + private static ProtocolContext contextWith(final Collection

validators) { + final ValidatorProvider validatorProvider = + mock(ValidatorProvider.class, withSettings().strictness(Strictness.LENIENT)); + when(validatorProvider.getValidatorsForBlock(any())).thenReturn(validators); + when(validatorProvider.getValidatorsAfterBlock(any())).thenReturn(validators); + final BftContext bftContext = + mock(BftContext.class, withSettings().strictness(Strictness.LENIENT)); + when(bftContext.getValidatorProvider()).thenReturn(validatorProvider); + when(bftContext.as(any())).thenReturn(bftContext); + return new ProtocolContext.Builder().withConsensusContext(bftContext).build(); + } + + private static List systemPropertiesStartingWith(final String prefix) { + final Properties p = System.getProperties(); + return p.stringPropertyNames().stream() + .filter(n -> n.startsWith(prefix)) + .sorted() + .collect(Collectors.toList()); + } + + private static void clearOwnedProperties() { + for (final String p : OWNED_PROPERTIES) { + System.clearProperty(p); + } + } + + private static void forgetAnchorConfig() { + PqAnchorProducer.useConfigForTesting(null); + } + + private static void resetFalconSingleton() throws Exception { + final Field f = FalconSealSupport.class.getDeclaredField("instance"); + f.setAccessible(true); + f.set(null, null); + } + + /** + * A Log4j2 appender built as a dynamic proxy and attached to the root logger, so that this module + * can read the real logging pipeline without taking a compile dependency on {@code log4j-core}. + * + *

{@link #attach()} throws if any reflective step fails. It does NOT fall back to a silent + * captor: a captor that quietly captures nothing is precisely the failure this class is written to + * exclude. + */ + private static final class LogCaptor { + + private final List lines = Collections.synchronizedList(new ArrayList<>()); + private final Object rootLoggerConfig; + private final Object loggerContext; + private final Level priorLevel; + + private LogCaptor( + final Object rootLoggerConfig, final Object loggerContext, final Level priorLevel) { + this.rootLoggerConfig = rootLoggerConfig; + this.loggerContext = loggerContext; + this.priorLevel = priorLevel; + } + + static LogCaptor attach() throws Exception { + final Class appenderCls = Class.forName("org.apache.logging.log4j.core.Appender"); + final Class eventCls = Class.forName("org.apache.logging.log4j.core.LogEvent"); + final Class configCls = Class.forName("org.apache.logging.log4j.core.config.Configuration"); + final Class loggerConfigCls = + Class.forName("org.apache.logging.log4j.core.config.LoggerConfig"); + final Class filterCls = Class.forName("org.apache.logging.log4j.core.Filter"); + final Class ctxCls = Class.forName("org.apache.logging.log4j.core.LoggerContext"); + final Class stateCls = Class.forName("org.apache.logging.log4j.core.LifeCycle$State"); + + // LogManager is reached reflectively as well, not out of symmetry but because the build bans + // the symbol: [BannedMethod] "Do not use org.apache.logging.log4j.LogManager, use + // org.slf4j.LoggerFactory instead", and the ban is right for production code. A test that + // needs to inspect the logging pipeline itself is the one place it cannot be honoured, and + // going through the SLF4J facade cannot reach the appender list at all. + final Class logManagerCls = Class.forName("org.apache.logging.log4j.LogManager"); + final Object ctx = + logManagerCls.getMethod("getContext", boolean.class).invoke(null, Boolean.FALSE); + if (!ctxCls.isInstance(ctx)) { + throw new IllegalStateException( + "the SLF4J binding in this JVM is not log4j-core, so the log cannot be tapped: " + + ctx.getClass().getName()); + } + final Object configuration = ctxCls.getMethod("getConfiguration").invoke(ctx); + final Object rootLoggerConfig = configCls.getMethod("getRootLogger").invoke(configuration); + + final List sink = Collections.synchronizedList(new ArrayList<>()); + final Method getMessage = eventCls.getMethod("getMessage"); + final Method getLoggerName = eventCls.getMethod("getLoggerName"); + Object startedState = null; + for (final Object c : stateCls.getEnumConstants()) { + if ("STARTED".equals(((Enum) c).name())) { + startedState = c; + } + } + final Object started = startedState; + + final InvocationHandler handler = + (proxy, method, args) -> { + switch (method.getName()) { + case "append": + final Object event = args[0]; + final Object msg = getMessage.invoke(event); + final String text = + (String) msg.getClass().getMethod("getFormattedMessage").invoke(msg); + sink.add(getLoggerName.invoke(event) + " | " + text); + return null; + case "getName": + return "aere-d147-captor"; + case "isStarted": + return Boolean.TRUE; + case "isStopped": + return Boolean.FALSE; + case "getState": + return started; + case "ignoreExceptions": + return Boolean.TRUE; + case "equals": + return proxy == args[0]; + case "hashCode": + return System.identityHashCode(proxy); + case "toString": + return "aere-d147-captor"; + default: + return null; + } + }; + final Object appender = + Proxy.newProxyInstance( + PqInertBinaryTest.class.getClassLoader(), new Class[] {appenderCls}, handler); + + final Level prior = (Level) loggerConfigCls.getMethod("getLevel").invoke(rootLoggerConfig); + loggerConfigCls + .getMethod("addAppender", appenderCls, Level.class, filterCls) + .invoke(rootLoggerConfig, appender, Level.ALL, null); + loggerConfigCls.getMethod("setLevel", Level.class).invoke(rootLoggerConfig, Level.ALL); + ctxCls.getMethod("updateLoggers").invoke(ctx); + + final LogCaptor captor = new LogCaptor(rootLoggerConfig, ctx, prior); + captor.bind(sink); + return captor; + } + + /** The proxy writes into its own list; this keeps a single reading surface. */ + private List bound; + + private void bind(final List sink) { + this.bound = sink; + } + + void detach() throws Exception { + final Class loggerConfigCls = + Class.forName("org.apache.logging.log4j.core.config.LoggerConfig"); + final Class ctxCls = Class.forName("org.apache.logging.log4j.core.LoggerContext"); + loggerConfigCls + .getMethod("removeAppender", String.class) + .invoke(rootLoggerConfig, "aere-d147-captor"); + loggerConfigCls.getMethod("setLevel", Level.class).invoke(rootLoggerConfig, priorLevel); + ctxCls.getMethod("updateLoggers").invoke(loggerContext); + lines.addAll(bound); + } + + int total() { + return lines.size(); + } + + /** Every captured line that names the ANCHOR, by logger or by wording. */ + List anchorLines() { + return lines.stream() + .filter( + l -> { + final String lower = l.toLowerCase(Locale.ROOT); + return ANCHOR_LOGGERS.contains(loggerOf(l)) + || ANCHOR_WORDS.stream().anyMatch(lower::contains); + }) + .collect(Collectors.toList()); + } + + /** Every captured message body that AERE code emitted, logger prefix stripped. */ + List aereLines() { + return lines.stream() + .filter(l -> loggerOf(l).contains(".bft") || l.contains("AERE")) + .map(l -> l.substring(l.indexOf(" | ") + 3)) + .collect(Collectors.toList()); + } + + private static String loggerOf(final String line) { + final int i = line.indexOf(" | "); + return i < 0 ? "" : line.substring(0, i); + } + } +} diff --git a/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqParentHeightAlignmentTest.java b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqParentHeightAlignmentTest.java new file mode 100755 index 000000000..393cbeac4 --- /dev/null +++ b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqParentHeightAlignmentTest.java @@ -0,0 +1,229 @@ +/* + * Copyright contributors to Besu / AERE Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer; +import org.hyperledger.besu.datatypes.Address; + +import java.lang.reflect.Field; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.OptionalInt; + +import org.apache.tuweni.bytes.Bytes; +import org.apache.tuweni.bytes.Bytes32; +import org.bouncycastle.crypto.digests.KeccakDigest; +import org.bouncycastle.pqc.crypto.falcon.FalconPrivateKeyParameters; +import org.bouncycastle.pqc.crypto.falcon.FalconSigner; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * D-228 (2026-08-15): the PARENT-HEIGHT question at the first anchor. + * + *

WHAT WAS MEASURED, on a public node synced from genesis on the live chain. {@code + * PqAnchorSealsRule} judges the certificate carried by the block at the first anchor height H by + * asking the registry at the PARENT's height H-1, because that is the height the seals commit to. + * The schedule binds INCLUSIVELY from H, so at H-1 it binds nothing, and both height-resolved + * lookups fell back to the HEAD registry - which on a public node synced from genesis is EMPTY. A + * perfectly valid certificate was refused with "certificate carries index 0, which the registry + * does not bind to any validator address", and the node parked one block below the first anchor + * forever (measured: head 13,013,999 = H-1, 6,646 R2 rejections in the log). + * + *

THE REPAIR UNDER TEST. In the branch where {@code requiredHashAt(schedule, blockNumber)} is + * empty, before the head fallback: when the schedule has an entry at EXACTLY {@code blockNumber+1}, + * answer from that entry's VERIFIED bound registry. The only historical question ever asked at H-1 + * is about the certificate carried by the block at H, whose governing registry is the one bound at + * H; the alignment uses the verified v2 registry, not an unverified head file. + * + *

THE FIXTURE IS THE PUBLIC-NODE SHAPE, DELIBERATELY: no {@code aere.falcon.genesis}, so the + * head registry is EMPTY, and the registry reaches the node ONLY as history ({@code + * aere.falcon.registry.history}) bound by hash to the schedule entry at H. That emptiness is what + * makes the negative control possible - with a populated head, the H-1 assertions would pass + * through the old head fallback and prove nothing about the repair. {@link + * #baselineTheHeadIsEmptyOtherwiseNothingHereMeansAnything()} pins that shape. + */ +public class PqParentHeightAlignmentTest { + + /** Anchor activation height H: the schedule's FIRST entry sits exactly here. */ + private static final long H = 1_000L; + + /** Height from which the staged threshold is non-zero, i.e. the fully armed regime. */ + private static final long K_AT = H + 10L; + + private static final int N = 7; + + private static final long CHAIN_ID = 220_878L; + + @TempDir private Path tmp; + + private final List privateKeys = new ArrayList<>(); + private final List

validators = new ArrayList<>(); + private Path genesisPath; + + /** A fixed 32-byte message, standing in for M(parent) of the first anchor block. */ + private static final Bytes32 MESSAGE = Bytes32.fromHexString("0x" + "5a".repeat(32)); + + private Bytes sealByIndexZero; + + @BeforeEach + public void setUp() throws Exception { + // The same v2 proof-bound manifest as PqRegistryHeightRefusalTest, bound at H. + final KeccakDigest kd = new KeccakDigest(256); + final StringBuilder manifest = new StringBuilder(); + manifest + .append("{\"config\":{\"aereFalconRegistry\":{") + .append(PqV2Fixture.manifestHeader(N, CHAIN_ID, H)); + for (int i = 0; i < N; i++) { + privateKeys.add(PqV2Fixture.privateKey(i)); + validators.add(PqV2Fixture.address(i)); + final byte[] anchoredRow = PqV2Fixture.anchorPreimageRow(i); + kd.update(anchoredRow, 0, anchoredRow.length); + manifest.append(',').append(PqV2Fixture.manifestEntry(i, N, CHAIN_ID, H)); + } + manifest + .append("}},\"alloc\":{\"0000000000000000000000000000000000000fa1\":{\"storage\":{\"0x") + .append("0".repeat(64)) + .append("\":\"0x"); + final byte[] anchoredHash = new byte[32]; + kd.doFinal(anchoredHash, 0); + manifest.append(Bytes.wrap(anchoredHash).toUnprefixedHexString()).append("\"}}}}"); + + genesisPath = tmp.resolve("genesis-d228.json"); + Files.writeString(genesisPath, manifest.toString()); + // DELIBERATELY NOT setting aere.falcon.genesis: the head registry stays EMPTY, which is the + // public-node shape D-228 was measured on. + + // A genuine Falcon-512 signature by index 0 over MESSAGE. + final FalconSigner signer = new FalconSigner(); + signer.init(true, privateKeys.get(0)); + sealByIndexZero = Bytes.wrap(signer.generateSignature(MESSAGE.toArray())); + + resetFalconSingleton(); + // ARMED at H, exactly like the live fleet at its first anchor height. + PqAnchorProducer.useConfigForTesting( + new PqAnchorConfig(CHAIN_ID, H, Map.of(H, 0, K_AT, 3), OptionalInt.empty(), false)); + + // The registry reaches this node ONLY as history, exactly like the public package does it. + System.setProperty( + FalconSealSupport.PROPERTY_REGISTRY_HISTORY, genesisPath.toAbsolutePath().toString()); + final FalconSealSupport pqc = FalconSealSupport.instance(); + final PqRegistryHash.Registry held = PqRegistryHash.loadAuto(genesisPath); + final PqRegistryHash.Schedule schedule = + scheduleFromGenesis(Map.of(H, PqRegistryHash.hashFor(held, CHAIN_ID))); + pqc.verifyRegistryBindingOrAbort(0L, CHAIN_ID, schedule); + } + + @AfterEach + public void tearDown() throws Exception { + System.clearProperty("aere.falcon.genesis"); + System.clearProperty("aere.pq.genesis"); + System.clearProperty(FalconSealSupport.PROPERTY_REGISTRY_HISTORY); + resetFalconSingleton(); + PqAnchorProducer.useConfigForTesting(null); + } + + // ------------------------------------------------------------------------------------------- + // 0. The fixture itself: the head must be EMPTY, or the two tests below pass through the old + // head fallback and the negative control cannot go red. + // ------------------------------------------------------------------------------------------- + + @Test + public void baselineTheHeadIsEmptyOtherwiseNothingHereMeansAnything() { + final FalconSealSupport pqc = FalconSealSupport.instance(); + assertThat(pqc.addressForIndex(0)) + .describedAs("the HEAD registry must be empty: this is the public-node shape") + .isNull(); + assertThat(pqc.verify(0, MESSAGE, sealByIndexZero)) + .describedAs("a head verify must fail for the same reason") + .isFalse(); + } + + // ------------------------------------------------------------------------------------------- + // 1. THE MEASUREMENT. At H-1, the question is about the certificate of the block at H, and the + // registry bound at H answers it. + // ------------------------------------------------------------------------------------------- + + @Test + public void atTheParentOfTheFirstAnchorTheBoundRegistryAnswers() { + final FalconSealSupport pqc = FalconSealSupport.instance(); + assertThat(pqc.addressForIndexAtHistoric(H - 1L, 0)) + .describedAs( + "D-228: PqAnchorSealsRule asks at the PARENT height H-1 about the certificate carried " + + "by the block at H. The registry governing that certificate is the one bound at " + + "H, and it is VERIFIED; refusing here parks a syncing node at H-1 forever") + .isEqualTo(validators.get(0)); + assertThat(pqc.verifyAtHistoric(H - 1L, 0, MESSAGE, sealByIndexZero)) + .describedAs("and the key half must answer from the same bound registry") + .isTrue(); + } + + // ------------------------------------------------------------------------------------------- + // 2. THE BOUNDARY IS EXACTLY ONE BLOCK. At H-2 and below, nothing changes: the head fallback + // still answers, and on this empty head that answer is a refusal, exactly as before. + // ------------------------------------------------------------------------------------------- + + @Test + public void theBoundaryIsExactlyOneBlock() { + final FalconSealSupport pqc = FalconSealSupport.instance(); + assertThat(pqc.addressForIndexAtHistoric(H - 2L, 0)) + .describedAs("H-2 is not the parent of any anchor-governed height: old behaviour, refusal") + .isNull(); + assertThat(pqc.verifyAtHistoric(H - 2L, 0, MESSAGE, sealByIndexZero)).isFalse(); + assertThat(pqc.addressForIndexAtHistoric(0L, 0)) + .describedAs("and deep below, the same") + .isNull(); + } + + // ------------------------------------------------------------------------------------------- + // Helpers, borrowed from PqRegistryHeightRefusalTest. + // ------------------------------------------------------------------------------------------- + + private PqRegistryHash.Schedule scheduleFromGenesis(final Map entries) + throws Exception { + final List heights = new ArrayList<>(entries.keySet()); + heights.sort(Long::compare); + final StringBuilder sb = new StringBuilder("{\"config\":{\"pqRegistryHash\":["); + for (int i = 0; i < heights.size(); i++) { + if (i > 0) { + sb.append(','); + } + final long b = heights.get(i); + String h = entries.get(b); + if (!h.startsWith("0x")) { + h = "0x" + h; + } + sb.append("{\"block\":").append(b).append(",\"hash\":\"").append(h).append("\"}"); + } + sb.append("]}}"); + final Path p = + tmp.resolve("genesis-schedule-" + heights.size() + "-" + heights.get(0) + ".json"); + Files.writeString(p, sb.toString()); + return PqRegistryHash.loadScheduleFromGenesis(p); + } + + private static void resetFalconSingleton() throws Exception { + final Field f = FalconSealSupport.class.getDeclaredField("instance"); + f.setAccessible(true); + f.set(null, null); + } +} diff --git a/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqRegistryBindingTest.java b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqRegistryBindingTest.java new file mode 100755 index 000000000..dae5cb9b6 --- /dev/null +++ b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqRegistryBindingTest.java @@ -0,0 +1,595 @@ +/* + * Copyright contributors to Besu. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.hyperledger.besu.crypto.KeyPair; +import org.hyperledger.besu.crypto.SECPSignature; +import org.hyperledger.besu.crypto.SecureRandomProvider; +import org.hyperledger.besu.crypto.SignatureAlgorithm; +import org.hyperledger.besu.crypto.SignatureAlgorithmFactory; +import org.hyperledger.besu.datatypes.Address; +import org.hyperledger.besu.ethereum.core.Util; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.List; + +import org.apache.tuweni.bytes.Bytes; +import org.apache.tuweni.bytes.Bytes32; +import org.bouncycastle.crypto.AsymmetricCipherKeyPair; +import org.bouncycastle.pqc.crypto.falcon.FalconKeyGenerationParameters; +import org.bouncycastle.pqc.crypto.falcon.FalconKeyPairGenerator; +import org.bouncycastle.pqc.crypto.falcon.FalconParameters; +import org.bouncycastle.pqc.crypto.falcon.FalconPrivateKeyParameters; +import org.bouncycastle.pqc.crypto.falcon.FalconPublicKeyParameters; +import org.bouncycastle.pqc.crypto.falcon.FalconSigner; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * AERE D-146. The registry says validator i has Falcon key k. Nothing said validator i ever agreed + * to that, or that anybody holds k's secret. + * + *

WHAT WAS MEASURED BEFORE THIS TEST EXISTED, on the real verification path, with the startup + * gate reporting MATCH and the header ACCEPTED every time: + * + *

    + *
  • T3, one row's address repointed: a seal made by validator 0's key credited to validator 1. + *
  • T4, one key at two indices: two seals from a SINGLE private key satisfy a threshold of 2. + *
  • T6A, two rows' keys swapped: accepted, and the registry has NO duplicate key and NO + * duplicate address, so no uniqueness rule can ever see it. + *
  • T6B, two rows' addresses swapped: accepted, again with nothing duplicated, and every seal + * attributed to the wrong validator. + *
+ * + *

T6A and T6B are the reason this test class needs signatures and not just a set. Uniqueness + * makes the THRESHOLD honest again; only a signature by the validator's own consensus key makes the + * ATTRIBUTION honest. + * + *

KEYS. Every Falcon and ECDSA key here is generated in memory, used inside one test method, and + * never written anywhere but a JUnit temporary directory. Nothing in this file touches the key + * ceremony, the vault, or the three locks that stand in front of real key generation. + */ +class PqRegistryBindingTest { + + private static final long CHAIN_ID = 2800L; + private static final long OTHER_CHAIN_ID = 442807L; + private static final long BIND_HEIGHT = 12_000_000L; + private static final int N = 4; + + /** One validator's material: a Falcon pair and the ECDSA consensus key that must vouch for it. */ + private record Holder( + byte[] falconPublicKey, FalconPrivateKeyParameters falconPrivate, KeyPair ecdsa) { + Address address() { + return Util.publicKeyToAddress(ecdsa.getPublicKey()); + } + } + + private static List holders; + private static SignatureAlgorithm ecdsa; + + @BeforeAll + static void generateProbeKeys() { + ecdsa = SignatureAlgorithmFactory.getInstance(); + final SecureRandom rnd = SecureRandomProvider.publicSecureRandom(); + final FalconKeyPairGenerator gen = new FalconKeyPairGenerator(); + gen.init(new FalconKeyGenerationParameters(rnd, FalconParameters.falcon_512)); + holders = new ArrayList<>(); + for (int i = 0; i < N; i++) { + final AsymmetricCipherKeyPair kp = gen.generateKeyPair(); + holders.add( + new Holder( + ((FalconPublicKeyParameters) kp.getPublic()).getH(), + (FalconPrivateKeyParameters) kp.getPrivate(), + ecdsa.generateKeyPair())); + } + } + + // =================================================================================== + // The positive control. Without this, every refusal below proves only that the loader + // refuses everything. + // =================================================================================== + + @Test + void anHonestlySignedRegistryLoadsAndIsProofBound(@TempDir final Path dir) throws IOException { + final Path f = writeRegistry(dir, "honest.properties", rows(), CHAIN_ID, BIND_HEIGHT); + + final PqRegistryHash.Registry r = PqRegistryHash.loadAuto(f); + + assertThat(r.count()).isEqualTo(N); + assertThat(r.addressBound()).isTrue(); + assertThat(r.proofBound()).isTrue(); + assertThat(r.declaredChainId()).isEqualTo(CHAIN_ID); + assertThat(r.bindHeight()).isEqualTo(BIND_HEIGHT); + for (final PqRegistryHash.Entry e : r.entries()) { + assertThat(e.possessionProof()).isNotNull().isNotEmpty(); + assertThat(e.claimProof()).hasSize(65); + } + } + + // =================================================================================== + // T3 and T6: attribution. These are the cases NO uniqueness rule can catch. + // =================================================================================== + + @Test + void t6aSwappingTwoRowsPublicKeysIsRefused(@TempDir final Path dir) throws IOException { + final List rows = rows(); + final byte[] pk0 = rows.get(0).publicKey; + rows.get(0).publicKey = rows.get(2).publicKey; + rows.get(2).publicKey = pk0; + final Path f = writeRegistry(dir, "t6a.properties", rows, CHAIN_ID, BIND_HEIGHT); + + // The registry still has four distinct keys and four distinct addresses. Measured on the real + // path on 2026-08-06, exactly this file produced an ACCEPTED header. + assertThat(distinct(rows, true)).isEqualTo(N); + assertThat(distinct(rows, false)).isEqualTo(N); + + assertThatThrownBy(() -> PqRegistryHash.loadAuto(f)) + .isInstanceOf(PqRegistryHash.RegistryConfigException.class) + .hasMessageContaining("AERE-PQC-REG-BIND-03"); + } + + @Test + void t6bSwappingTwoRowsAddressesIsRefused(@TempDir final Path dir) throws IOException { + final List rows = rows(); + final Address a0 = rows.get(0).address; + rows.get(0).address = rows.get(2).address; + rows.get(2).address = a0; + final Path f = writeRegistry(dir, "t6b.properties", rows, CHAIN_ID, BIND_HEIGHT); + + assertThat(distinct(rows, true)).isEqualTo(N); + assertThat(distinct(rows, false)).isEqualTo(N); + + assertThatThrownBy(() -> PqRegistryHash.loadAuto(f)) + .isInstanceOf(PqRegistryHash.RegistryConfigException.class) + .hasMessageContaining("AERE-PQC-REG-BIND-03"); + } + + @Test + void t3AKeyFiledUnderAnotherValidatorsAddressIsRefused(@TempDir final Path dir) + throws IOException { + // The exact T3 shape, with the proofs re-signed by the party that WOULD hold them at a key + // ceremony: the registry writer, who has every FALCON secret. It signs a perfectly valid + // possession proof for key 0 sitting under validator 1's address. Only the ECDSA claim, which + // needs validator 1's consensus key, stops it - and that is the whole argument for why a Falcon + // proof-of-possession alone does not repair D-146. + final List rows = rows(); + rows.get(0).address = holders.get(1).address(); + rows.get(1).address = holders.get(3).address(); // keep addresses distinct + rows.get(3).address = holders.get(0).address(); + resignAsRegistryWriter(rows); + final Path f = writeRegistry(dir, "t3.properties", rows, CHAIN_ID, BIND_HEIGHT); + + assertThat(distinct(rows, false)).isEqualTo(N); + + assertThatThrownBy(() -> PqRegistryHash.loadAuto(f)) + .isInstanceOf(PqRegistryHash.RegistryConfigException.class) + .hasMessageContaining("AERE-PQC-REG-BIND-04"); + } + + @Test + void aFalconPossessionProofAloneDoesNotBindTheAddress(@TempDir final Path dir) throws IOException { + // Stated as a test so nobody can quietly ship "we added proof of possession" as the repair. + // The possession proof below is genuine and verifies; the row is still a lie. + final List rows = rows(); + rows.get(0).address = holders.get(1).address(); + rows.get(1).address = holders.get(0).address(); + resignAsRegistryWriter(rows); + final Path f = writeRegistry(dir, "pop-only.properties", rows, CHAIN_ID, BIND_HEIGHT); + + final Row r0 = rows.get(0); + final Bytes32 popDigest = + PqRegistryBinding.possessionDigest( + CHAIN_ID, BIND_HEIGHT, N, 0, r0.address.getBytes().toArray(), r0.publicKey); + assertThat(PqRegistryBinding.verifyPossession(r0.publicKey, popDigest, r0.possession)) + .as("the Falcon possession proof is genuine and verifies") + .isTrue(); + + assertThatThrownBy(() -> PqRegistryHash.loadAuto(f)) + .as("and the registry is still refused, by the ECDSA half") + .isInstanceOf(PqRegistryHash.RegistryConfigException.class) + .hasMessageContaining("AERE-PQC-REG-BIND-04"); + } + + // =================================================================================== + // T4 and T5: the threshold. Uniqueness, no signature involved. + // =================================================================================== + + @Test + void t4TheSameKeyAtTwoIndicesIsRefused(@TempDir final Path dir) throws IOException { + final List rows = rows(); + rows.get(2).publicKey = rows.get(0).publicKey; + final Path f = writeRegistry(dir, "t4.properties", rows, CHAIN_ID, BIND_HEIGHT); + + assertThatThrownBy(() -> PqRegistryHash.loadAuto(f)) + .isInstanceOf(PqRegistryHash.RegistryConfigException.class) + .hasMessageContaining("AERE-PQC-REG-LOAD-19"); + } + + @Test + void t5TheSameAddressAtTwoIndicesIsRefused(@TempDir final Path dir) throws IOException { + final List rows = rows(); + rows.get(2).address = rows.get(0).address; + final Path f = writeRegistry(dir, "t5.properties", rows, CHAIN_ID, BIND_HEIGHT); + + assertThatThrownBy(() -> PqRegistryHash.loadAuto(f)) + .isInstanceOf(PqRegistryHash.RegistryConfigException.class) + .hasMessageContaining("AERE-PQC-REG-LOAD-20"); + } + + @Test + void t4AndT5AreRefusedInALEGACYRegistryToo(@TempDir final Path dir) throws IOException { + // The uniqueness half must not be reachable only through the new format, or a v1 file remains + // the way to ship a duplicated key. This is the ONE case where the before/after can be measured + // on the SAME bytes by the unpatched binary, and it is: the probe loads this file on the old + // tree and refuses it on the new one. + final StringBuilder b = new StringBuilder("count=" + N + "\n"); + for (int i = 0; i < N; i++) { + b.append(i).append('=').append(hex(holders.get(i == 2 ? 0 : i).falconPublicKey())).append('\n'); + b.append(i).append(".addr=").append(hex(holders.get(i).address().getBytes().toArray())).append('\n'); + } + final Path f = dir.resolve("legacy-dup.properties"); + Files.writeString(f, b.toString(), StandardCharsets.UTF_8); + + assertThatThrownBy(() -> PqRegistryHash.loadAuto(f)) + .isInstanceOf(PqRegistryHash.RegistryConfigException.class) + .hasMessageContaining("AERE-PQC-REG-LOAD-19"); + } + + // =================================================================================== + // Replay: the fields that are inside the signed message earn their place here. + // =================================================================================== + + @Test + void aProofLiftedToAnotherIndexIsRefused(@TempDir final Path dir) throws IOException { + final List rows = rows(); + final byte[] pop0 = rows.get(0).possession; + rows.get(0).possession = rows.get(1).possession; + rows.get(1).possession = pop0; + final Path f = writeRegistry(dir, "lift.properties", rows, CHAIN_ID, BIND_HEIGHT); + + assertThatThrownBy(() -> PqRegistryHash.loadAuto(f)) + .isInstanceOf(PqRegistryHash.RegistryConfigException.class) + .hasMessageContaining("AERE-PQC-REG-BIND-03"); + } + + @Test + void aRegistrySignedForAnotherHeightIsRefused(@TempDir final Path dir) throws IOException { + final Path f = writeRegistry(dir, "height.properties", rows(), CHAIN_ID, BIND_HEIGHT + 1); + + assertThatThrownBy(() -> PqRegistryHash.loadAuto(f)) + .isInstanceOf(PqRegistryHash.RegistryConfigException.class) + .hasMessageContaining("AERE-PQC-REG-BIND-03"); + } + + @Test + void aRegistryLiftedFromTheScratchChainIsRefusedAtTheGate(@TempDir final Path dir) + throws IOException { + // Signed for 442807, declared as 442807, internally perfect. It loads - the proofs really do + // verify - and the GATE refuses it, because the node is on 2800. + final Path f = + writeRegistry(dir, "scratch.properties", rows(OTHER_CHAIN_ID), OTHER_CHAIN_ID, BIND_HEIGHT); + final PqRegistryHash.Registry r = PqRegistryHash.loadAuto(f); + assertThat(r.proofBound()).isTrue(); + + assertThatThrownBy( + () -> + PqRegistryHash.verifyOrAbort( + PqRegistryHash.emptySchedule(), r, BIND_HEIGHT, CHAIN_ID)) + .isInstanceOf(PqRegistryHash.RegistryConfigException.class) + .hasMessageContaining("AERE-PQC-REG-BIND-07"); + } + + @Test + void aTruncatedProofIsRefused(@TempDir final Path dir) throws IOException { + final List rows = rows(); + final byte[] cut = new byte[rows.get(1).possession.length - 8]; + System.arraycopy(rows.get(1).possession, 0, cut, 0, cut.length); + rows.get(1).possession = cut; + final Path f = writeRegistry(dir, "cut.properties", rows, CHAIN_ID, BIND_HEIGHT); + + assertThatThrownBy(() -> PqRegistryHash.loadAuto(f)) + .isInstanceOf(PqRegistryHash.RegistryConfigException.class) + .hasMessageContaining("AERE-PQC-REG-BIND-03"); + } + + @Test + void aClaimSignedByTheWrongValidatorIsRefused(@TempDir final Path dir) throws IOException { + final List rows = rows(); + rows.get(1).claim = + signClaim(holders.get(2).ecdsa(), CHAIN_ID, BIND_HEIGHT, 1, rows.get(1)); + final Path f = writeRegistry(dir, "wrongsigner.properties", rows, CHAIN_ID, BIND_HEIGHT); + + assertThatThrownBy(() -> PqRegistryHash.loadAuto(f)) + .isInstanceOf(PqRegistryHash.RegistryConfigException.class) + .hasMessageContaining("AERE-PQC-REG-BIND-04"); + } + + @Test + void aPartiallyBoundRegistryIsRefused(@TempDir final Path dir) throws IOException { + final List rows = rows(); + rows.get(3).claim = null; + final Path f = writeRegistry(dir, "partial.properties", rows, CHAIN_ID, BIND_HEIGHT); + + assertThatThrownBy(() -> PqRegistryHash.loadAuto(f)) + .isInstanceOf(PqRegistryHash.RegistryConfigException.class) + .hasMessageContaining("AERE-PQC-REG-LOAD-21"); + } + + @Test + void proofsWithoutAChainIdOrBindHeightAreRefused(@TempDir final Path dir) throws IOException { + final Path f = writeRegistry(dir, "noheader.properties", rows(), -1L, -1L); + + assertThatThrownBy(() -> PqRegistryHash.loadAuto(f)) + .isInstanceOf(PqRegistryHash.RegistryConfigException.class) + .hasMessageContaining("AERE-PQC-REG-LOAD-22"); + } + + // =================================================================================== + // The hash must COVER the proofs, or they are advisory and can be stripped. + // =================================================================================== + + @Test + void theCanonicalHashCoversBothProofsOfEveryRow(@TempDir final Path dir) throws IOException { + final List rows = rows(); + final PqRegistryHash.Registry r = + PqRegistryHash.loadAuto(writeRegistry(dir, "cover.properties", rows, CHAIN_ID, BIND_HEIGHT)); + + final Bytes pre = Bytes.wrap(PqRegistryHash.canonicalPreimageV2(r, CHAIN_ID)); + for (final Row row : rows) { + assertThat(indexOf(pre, Bytes.wrap(row.possession))) + .as("possession proof is inside the hashed pre-image") + .isNotNegative(); + assertThat(indexOf(pre, Bytes.wrap(row.claim))) + .as("claim is inside the hashed pre-image") + .isNotNegative(); + } + assertThat(PqRegistryHash.hashFor(r, CHAIN_ID)).isEqualTo(PqRegistryHash.hashV2(r, CHAIN_ID)); + } + + @Test + void strippingTheProofsChangesTheHashSoADowngradeCannotSatisfyASchedule(@TempDir final Path dir) + throws IOException { + final List rows = rows(); + final PqRegistryHash.Registry bound = + PqRegistryHash.loadAuto(writeRegistry(dir, "bound.properties", rows, CHAIN_ID, BIND_HEIGHT)); + + // The same rows with the proof lines deleted: what an attacker writes to make the check + // disappear. It must not hash to the value genesis anchored. + final StringBuilder b = new StringBuilder("count=" + N + "\n"); + for (int i = 0; i < N; i++) { + b.append(i).append('=').append(hex(rows.get(i).publicKey)).append('\n'); + b.append(i).append(".addr=").append(hex(rows.get(i).address.getBytes().toArray())).append('\n'); + } + final Path stripped = dir.resolve("stripped.properties"); + Files.writeString(stripped, b.toString(), StandardCharsets.UTF_8); + final PqRegistryHash.Registry v1 = PqRegistryHash.loadAuto(stripped); + + assertThat(v1.proofBound()).isFalse(); + assertThat(PqRegistryHash.hashFor(v1, CHAIN_ID)) + .isNotEqualTo(PqRegistryHash.hashFor(bound, CHAIN_ID)); + + final PqRegistryHash.Schedule schedule = + PqRegistryHash.parseSchedule( + scheduleNode(PqRegistryHash.hashFor(bound, CHAIN_ID)), "test"); + assertThat(PqRegistryHash.matchesAt(schedule, bound, BIND_HEIGHT, CHAIN_ID)).isTrue(); + assertThat(PqRegistryHash.matchesAt(schedule, v1, BIND_HEIGHT, CHAIN_ID)).isFalse(); + } + + // =================================================================================== + // Domain separation: a possession proof must never be usable as an anchor seal. + // =================================================================================== + + @Test + void aPossessionProofIsNotASealForTheSameHeight(@TempDir final Path dir) throws IOException { + final List rows = rows(); + final Row r0 = rows.get(0); + final Bytes32 pop = + PqRegistryBinding.possessionDigest( + CHAIN_ID, BIND_HEIGHT, N, 0, r0.address.getBytes().toArray(), r0.publicKey); + final Bytes32 commit = + PqAnchor.commitMessage(CHAIN_ID, BIND_HEIGHT, Bytes32.repeat((byte) 0x7e)); + + assertThat(pop).isNotEqualTo(commit); + // The tag is at offset 0 of the pre-image, so the two message spaces cannot overlap by + // construction rather than by luck. + final byte[] pre = + PqRegistryBinding.bindingPreimage( + CHAIN_ID, BIND_HEIGHT, N, 0, r0.address.getBytes().toArray(), r0.publicKey); + assertThat(new String(pre, 0, PqRegistryBinding.DOMAIN_POSSESSION.length(), StandardCharsets.US_ASCII)) + .isEqualTo(PqRegistryBinding.DOMAIN_POSSESSION); + // A seal signed over the COMMIT message does not verify as a possession proof. + final byte[] sealOverCommit = falconSign(holders.get(0).falconPrivate(), commit); + assertThat(PqRegistryBinding.verifyPossession(r0.publicKey, pop, sealOverCommit)).isFalse(); + // ... and the possession proof does not verify as a seal. + assertThat(PqRegistryBinding.verifyPossession(r0.publicKey, commit, r0.possession)).isFalse(); + assertThat(dir).exists(); + } + + // =================================================================================== + // The arming precondition. + // =================================================================================== + + @Test + void armingOverAnUnboundRegistryIsRefused(@TempDir final Path dir) throws IOException { + final StringBuilder b = new StringBuilder("count=" + N + "\n"); + for (int i = 0; i < N; i++) { + b.append(i).append('=').append(hex(holders.get(i).falconPublicKey())).append('\n'); + b.append(i).append(".addr=").append(hex(holders.get(i).address().getBytes().toArray())).append('\n'); + } + final Path f = dir.resolve("unbound.properties"); + Files.writeString(f, b.toString(), StandardCharsets.UTF_8); + final PqRegistryHash.Registry v1 = PqRegistryHash.loadAuto(f); + + assertThatThrownBy(() -> PqRegistryHash.requireBindingsOrThrow(v1)) + .isInstanceOf(PqRegistryHash.RegistryConfigException.class) + .hasMessageContaining("AERE-PQC-REG-ARM-02"); + + final PqRegistryHash.Registry v2 = + PqRegistryHash.loadAuto(writeRegistry(dir, "armed.properties", rows(), CHAIN_ID, BIND_HEIGHT)); + PqRegistryHash.requireBindingsOrThrow(v2); // must not throw + } + + // =================================================================================== + // Fixtures + // =================================================================================== + + private static final class Row { + private byte[] publicKey; + private Address address; + private byte[] possession; + private byte[] claim; + } + + /** Honest rows for CHAIN_ID at BIND_HEIGHT, every proof signed by the party that should sign it. */ + private static List rows() { + return rows(CHAIN_ID); + } + + private static List rows(final long chainId) { + final List out = new ArrayList<>(); + for (int i = 0; i < N; i++) { + final Row r = new Row(); + r.publicKey = holders.get(i).falconPublicKey(); + r.address = holders.get(i).address(); + out.add(r); + } + for (int i = 0; i < N; i++) { + final Row r = out.get(i); + r.possession = + falconSign( + holders.get(i).falconPrivate(), + PqRegistryBinding.possessionDigest( + chainId, BIND_HEIGHT, N, i, r.address.getBytes().toArray(), r.publicKey)); + r.claim = signClaim(holders.get(i).ecdsa(), chainId, BIND_HEIGHT, i, r); + } + return out; + } + + /** + * Re-sign every row the way THE REGISTRY WRITER would at a key ceremony: it holds every FALCON + * secret, so it can always produce a valid possession proof for whatever row it just wrote. The + * ECDSA claim it can produce is the one belonging to the holder of the key on that row, never the + * one belonging to the address it filed the key under. That gap is the whole of D-146 and it is + * why the Falcon half alone repairs nothing. + */ + private static void resignAsRegistryWriter(final List rows) { + for (int i = 0; i < rows.size(); i++) { + final Row r = rows.get(i); + final Holder owner = ownerOfKey(r.publicKey); + r.possession = + falconSign( + owner.falconPrivate(), + PqRegistryBinding.possessionDigest( + CHAIN_ID, BIND_HEIGHT, N, i, r.address.getBytes().toArray(), r.publicKey)); + r.claim = signClaim(owner.ecdsa(), CHAIN_ID, BIND_HEIGHT, i, r); + } + } + + private static Holder ownerOfKey(final byte[] publicKey) { + for (final Holder h : holders) { + if (java.util.Arrays.equals(h.falconPublicKey(), publicKey)) { + return h; + } + } + throw new IllegalStateException("no probe holder owns that key"); + } + + private static byte[] signClaim( + final KeyPair kp, final long chainId, final long height, final int index, final Row r) { + final Bytes32 d = + PqRegistryBinding.claimDigest( + chainId, height, N, index, r.address.getBytes().toArray(), r.publicKey); + final SECPSignature s = ecdsa.sign(d, kp); + return s.encodedBytes().toArrayUnsafe(); + } + + private static byte[] falconSign(final FalconPrivateKeyParameters priv, final Bytes32 message) { + final FalconSigner signer = new FalconSigner(); + signer.init(true, priv); + return signer.generateSignature(message.toArray()); + } + + private static Path writeRegistry( + final Path dir, + final String name, + final List rows, + final long chainId, + final long bindHeight) + throws IOException { + final StringBuilder b = new StringBuilder(); + b.append("formatVersion=").append(PqRegistryBinding.FORMAT_VERSION).append('\n'); + if (chainId >= 0) { + b.append("chainId=").append(chainId).append('\n'); + } + if (bindHeight >= 0) { + b.append("bindHeight=").append(bindHeight).append('\n'); + } + b.append("count=").append(rows.size()).append('\n'); + for (int i = 0; i < rows.size(); i++) { + final Row r = rows.get(i); + b.append(i).append('=').append(hex(r.publicKey)).append('\n'); + b.append(i).append(".addr=").append(hex(r.address.getBytes().toArray())).append('\n'); + if (r.possession != null) { + b.append(i).append(".pop=").append(hex(r.possession)).append('\n'); + } + if (r.claim != null) { + b.append(i).append(".claim=").append(hex(r.claim)).append('\n'); + } + } + final Path f = dir.resolve(name); + Files.writeString(f, b.toString(), StandardCharsets.UTF_8); + return f; + } + + private static long distinct(final List rows, final boolean keys) { + return rows.stream() + .map(r -> keys ? hex(r.publicKey) : hex(r.address.getBytes().toArray())) + .distinct() + .count(); + } + + private static int indexOf(final Bytes haystack, final Bytes needle) { + for (int i = 0; i + needle.size() <= haystack.size(); i++) { + if (haystack.slice(i, needle.size()).equals(needle)) { + return i; + } + } + return -1; + } + + private static com.fasterxml.jackson.databind.JsonNode scheduleNode(final String hash) { + final com.fasterxml.jackson.databind.node.ArrayNode a = + new com.fasterxml.jackson.databind.ObjectMapper().createArrayNode(); + a.addObject().put("block", BIND_HEIGHT).put("hash", "0x" + hash); + return a; + } + + private static String hex(final byte[] b) { + final StringBuilder s = new StringBuilder(b.length * 2); + for (final byte x : b) { + s.append(Character.forDigit((x >> 4) & 0xf, 16)).append(Character.forDigit(x & 0xf, 16)); + } + return s.toString(); + } +} diff --git a/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqRegistryHeightRefusalTest.java b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqRegistryHeightRefusalTest.java new file mode 100755 index 000000000..2b7a89cd5 --- /dev/null +++ b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqRegistryHeightRefusalTest.java @@ -0,0 +1,326 @@ +/* + * Copyright contributors to Besu / AERE Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer; +import org.hyperledger.besu.datatypes.Address; + +import java.lang.reflect.Field; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.OptionalInt; + +import org.apache.tuweni.bytes.Bytes; +import org.apache.tuweni.bytes.Bytes32; +import org.bouncycastle.crypto.digests.KeccakDigest; +import org.bouncycastle.pqc.crypto.falcon.FalconPrivateKeyParameters; +import org.bouncycastle.pqc.crypto.falcon.FalconSigner; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * D2, the adversarial review of 2026-08-02, at the layer that actually answers the question. + * + *

WHAT THE DOSSIER MEASURED. {@code PqSignerRegistry} had {@code addressForIndex(int)} and {@code + * verify(int, Bytes, Bytes)} with no height, and {@code FalconSealSupport} held ONE registry loaded + * at start-up. So a header that passed both anchor rules was REJECTED the moment index 0's Falcon + * key was rotated - same header, same parent, same validator set. + * + *

WHAT WAS REPAIRED BEFORE THIS FILE, AND WHAT WAS NOT. Commit f3ebe90c (D-081) gave the + * validation path {@code addressForIndexAt} / {@code verifyAt} and a height-indexed schedule. The + * measurement of 2026-08-05 found the repair INERT, for a reason that is one line long: with no + * {@code config.pqRegistryHash} in genesis - and there is none in any genesis this fleet runs - + * {@code keyAt} fell back to the registry in force AT THE HEAD, at every height. Height-aware + * signatures, head-registry answers. T2 stood exactly as measured. + * + *

WHAT THIS FILE ASSERTS, as a property and not as a scenario: at and above the arming height, + * a node that cannot say which key set was in force must REFUSE, not guess. Below the arming + * height it must keep answering from the head registry, because nothing there is being judged and + * the 11.8 million blocks already on chain 2800 must behave bit for bit as they did. + * + *

THE NEGATIVE CONTROL IS BUILT IN, not promised. {@link + * #belowTheArmingHeightTheHeadRegistryStillAnswers()} fails if the refusal is made unconditional; + * {@link #whenTheAnchorIsNotArmedNOTHINGCHANGES()} fails if it is made independent of arming; {@link + * #withTheScheduleConfiguredTheArmedHeightsAnswerAgain()} fails if the refusal is anything other + * than a missing height-to-registry binding. And the measurement itself, {@link + * #d2t2AtAndAboveTheArmingHeightWithNoScheduleTheAnswerIsRefusal()}, is GREEN on the unrepaired code + * only if the fallback is restored - which is exactly the one-line edit the repair removed. + */ +public class PqRegistryHeightRefusalTest { + + /** Anchor activation height H: from here a header's Falcon certificate carries weight. */ + private static final long H = 1_000L; + + /** Height from which the staged threshold is non-zero, i.e. the fully armed regime. */ + private static final long K_AT = H + 10L; + + /** A height far above H, standing in for "a year of history above the arming height". */ + private static final long DEEP = K_AT + 5_000L; + + private static final int N = 7; + + private static final long CHAIN_ID = 220_878L; + + @TempDir private Path tmp; + + private final List privateKeys = new ArrayList<>(); + private final List

validators = new ArrayList<>(); + private Path genesisPath; + + /** A fixed 32-byte message, standing in for M(parent) or a committed-seal hash. */ + private static final Bytes32 MESSAGE = + Bytes32.fromHexString("0x" + "5a".repeat(32)); + + private Bytes sealByIndexZero; + + @BeforeEach + public void setUp() throws Exception { + // AERE D-146 (2026-08-06): v2, proof-bound, bound at H. See PqV2Fixture for why the addresses + // are derived from real secp256k1 keys and can no longer be spelled 0xA00+i. + final KeccakDigest kd = new KeccakDigest(256); + final StringBuilder manifest = new StringBuilder(); + manifest + .append("{\"config\":{\"aereFalconRegistry\":{") + .append(PqV2Fixture.manifestHeader(N, CHAIN_ID, H)); + for (int i = 0; i < N; i++) { + privateKeys.add(PqV2Fixture.privateKey(i)); + validators.add(PqV2Fixture.address(i)); + final byte[] anchoredRow = PqV2Fixture.anchorPreimageRow(i); + kd.update(anchoredRow, 0, anchoredRow.length); + manifest.append(',').append(PqV2Fixture.manifestEntry(i, N, CHAIN_ID, H)); + } + manifest + .append("}},\"alloc\":{\"0000000000000000000000000000000000000fa1\":{\"storage\":{\"0x") + .append("0".repeat(64)) + .append("\":\"0x"); + final byte[] anchoredHash = new byte[32]; + kd.doFinal(anchoredHash, 0); + manifest.append(Bytes.wrap(anchoredHash).toUnprefixedHexString()).append("\"}}}}"); + + genesisPath = tmp.resolve("genesis-d2.json"); + Files.writeString(genesisPath, manifest.toString()); + System.setProperty("aere.falcon.genesis", genesisPath.toAbsolutePath().toString()); + + // A genuine Falcon-512 signature by index 0 over MESSAGE. Everything below asks one question of + // it: at which heights does the node agree that this is index 0's signature. + final FalconSigner signer = new FalconSigner(); + signer.init(true, privateKeys.get(0)); + sealByIndexZero = Bytes.wrap(signer.generateSignature(MESSAGE.toArray())); + + resetFalconSingleton(); + // ARMED at H. On the live fleet aere.pq.anchorBlock is unset and this whole file's subject + // does not exist; see whenTheAnchorIsNotArmedNOTHINGCHANGES. + PqAnchorProducer.useConfigForTesting( + new PqAnchorConfig(CHAIN_ID, H, Map.of(H, 0, K_AT, 3), OptionalInt.empty(), false)); + } + + @AfterEach + public void tearDown() throws Exception { + System.clearProperty("aere.falcon.genesis"); + System.clearProperty("aere.pq.genesis"); + System.clearProperty(FalconSealSupport.PROPERTY_REGISTRY_HISTORY); + resetFalconSingleton(); + PqAnchorProducer.useConfigForTesting(null); + } + + // ------------------------------------------------------------------------------------------- + // 0. The fixture itself. Without this, a green run below could mean the registry never loaded. + // ------------------------------------------------------------------------------------------- + + @Test + public void baselineTheFixtureIsGenesisAnchoredAndTheSealIsGENUINE() { + final FalconSealSupport pqc = FalconSealSupport.instance(); + assertThat(pqc.genesisAnchored()) + .describedAs("the fixture must load a GENESIS-ANCHORED registry, or nothing here means anything") + .isTrue(); + assertThat(pqc.addressBound()).isTrue(); + assertThat(pqc.verify(0, MESSAGE, sealByIndexZero)) + .describedAs("the seal must be a REAL Falcon signature under the head registry") + .isTrue(); + } + + // ------------------------------------------------------------------------------------------- + // 1. THE MEASUREMENT. Chain 2800 as it stands: armed, and no pqRegistryHash anywhere. + // ------------------------------------------------------------------------------------------- + + @Test + public void d2t2AtAndAboveTheArmingHeightWithNoScheduleTheAnswerIsRefusal() { + final FalconSealSupport pqc = FalconSealSupport.instance(); + + // No schedule was ever loaded: verifyRegistryBindingOrAbort has not run, which is the state of + // every node on chain 2800 today, because config.pqRegistryHash is in no genesis this fleet + // runs (measured 2026-08-05, grep over deploy/ and monitoring/ returns nothing). + assertThat(pqc.verifyAtHistoric(H, 0, MESSAGE, sealByIndexZero)) + .describedAs( + "D2/T2: at the arming height itself, a node with no height-to-registry binding must " + + "REFUSE. Before 2026-08-06 it answered from the registry in force at the HEAD, " + + "so one key rotation made every block above H unverifiable while the node " + + "reported success") + .isFalse(); + + assertThat(pqc.verifyAtHistoric(DEEP, 0, MESSAGE, sealByIndexZero)) + .describedAs("and the same, far above the arming height") + .isFalse(); + + assertThat(pqc.addressForIndexAtHistoric(DEEP, 0)) + .describedAs( + "the address half must refuse identically: PqAnchorSealsRule refuses an index it " + + "cannot bind, and a bound-by-guess address is worse than an unbound one") + .isNull(); + } + + // ------------------------------------------------------------------------------------------- + // 2. Negative control: the refusal is HEIGHT-GATED. A rule that always refuses is not a repair. + // ------------------------------------------------------------------------------------------- + + @Test + public void belowTheArmingHeightTheHeadRegistryStillAnswers() { + final FalconSealSupport pqc = FalconSealSupport.instance(); + assertThat(pqc.verifyAtHistoric(H - 1L, 0, MESSAGE, sealByIndexZero)) + .describedAs( + "one block below H nothing is being judged, so the head registry is the right answer " + + "and the 11.8 million blocks already on chain must behave exactly as before") + .isTrue(); + assertThat(pqc.verifyAtHistoric(0L, 0, MESSAGE, sealByIndexZero)).isTrue(); + assertThat(pqc.addressForIndexAtHistoric(H - 1L, 0)).isEqualTo(validators.get(0)); + } + + // ------------------------------------------------------------------------------------------- + // 3. Negative control: the refusal is ARMING-gated. This is the proof that the live fleet is + // untouched, and it is the assertion that fails first if that stops being true. + // ------------------------------------------------------------------------------------------- + + @Test + public void whenTheAnchorIsNotArmedNOTHINGCHANGES() { + PqAnchorProducer.useConfigForTesting(PqAnchorConfig.never(CHAIN_ID)); + final FalconSealSupport pqc = FalconSealSupport.instance(); + assertThat(pqc.verifyAtHistoric(DEEP, 0, MESSAGE, sealByIndexZero)) + .describedAs( + "chain 2800 today: aere.pq.anchorBlock unset, so there is no arming height, no height " + + "is at or above it, and every answer is what it was before this repair") + .isTrue(); + assertThat(pqc.addressForIndexAtHistoric(DEEP, 0)).isEqualTo(validators.get(0)); + } + + // ------------------------------------------------------------------------------------------- + // 4. Positive control: what the refusal is a refusal ABOUT. Configure the binding and the armed + // heights answer again - through the height-resolved path, not the head-registry fallback. + // ------------------------------------------------------------------------------------------- + + @Test + public void withTheScheduleConfiguredTheArmedHeightsAnswerAgain() throws Exception { + final FalconSealSupport pqc = FalconSealSupport.instance(); + final PqRegistryHash.Registry held = PqRegistryHash.loadAuto(genesisPath); + final String hash = PqRegistryHash.hashFor(held, CHAIN_ID); + + // The first scheduled entry sits EXACTLY at the arming height, which is the rule the epoch-list + // design states: below H requiredHashAt is empty and the fallback is unreachable by anything + // that decides a header. + // AERE D-146 (2026-08-06): the hash above is hashFor, not hashV1, because this fixture's + // registry is now v2 and hashes under a different domain tag. A schedule entry that names the + // v1 number names a registry this node does not hold. + final PqRegistryHash.Schedule schedule = scheduleFromGenesis(Map.of(H, hash)); + pqc.verifyRegistryBindingOrAbort(0L, CHAIN_ID, schedule); + + assertThat(pqc.verifyAtHistoric(DEEP, 0, MESSAGE, sealByIndexZero)) + .describedAs( + "with the epoch bound at H and the registry held, the armed heights resolve through " + + "the schedule. If this is false the refusal is not about a missing binding and " + + "the measurement above proves nothing") + .isTrue(); + assertThat(pqc.addressForIndexAtHistoric(DEEP, 0)).isEqualTo(validators.get(0)); + assertThat(pqc.verifyAtHistoric(H - 1L, 0, MESSAGE, sealByIndexZero)) + .describedAs("and below H the fallback is still the answer") + .isTrue(); + } + + // ------------------------------------------------------------------------------------------- + // 5. The case the epoch list exists FOR: an epoch this node does not hold. Refused, and named. + // ------------------------------------------------------------------------------------------- + + @Test + public void anEpochThisNodeDoesNotHoldIsRefusedAndNAMED() throws Exception { + final FalconSealSupport pqc = FalconSealSupport.instance(); + final PqRegistryHash.Registry held = PqRegistryHash.loadAuto(genesisPath); + final String hash = PqRegistryHash.hashFor(held, CHAIN_ID); + final long rotation = K_AT + 1_000L; + + // Two epochs: the one this node holds, and a rotation to a registry it was never given. This is + // the shape of "an operator rotated a compromised key and one node did not get the file". + final PqRegistryHash.Schedule schedule = + scheduleFromGenesis( + new java.util.LinkedHashMap<>( + Map.of(H, hash, rotation, "0x" + "cd".repeat(32)))); + pqc.verifyRegistryBindingOrAbort(0L, CHAIN_ID, schedule); + + assertThat(pqc.verifyAtHistoric(rotation - 1L, 0, MESSAGE, sealByIndexZero)) + .describedAs("below the rotation this node holds the epoch and answers") + .isTrue(); + assertThat(pqc.verifyAtHistoric(rotation, 0, MESSAGE, sealByIndexZero)) + .describedAs( + "at the rotation the epoch is covered by NOTHING this node holds. It stops here; it " + + "does not answer from whatever it happens to have") + .isFalse(); + assertThat(pqc.addressForIndexAtHistoric(rotation, 0)).isNull(); + } + + // ------------------------------------------------------------------------------------------- + // Helpers. + // ------------------------------------------------------------------------------------------- + + /** + * Build a schedule the way a node really gets one: written into a genesis file as {@code + * config.pqRegistryHash} and parsed back. Constructing the object directly would skip the parser, + * which is where the strictly-increasing rule lives. + * + * @param entries height to 0x-prefixed registry hash, in ascending order of height + * @return the parsed schedule + * @throws Exception when the temporary genesis cannot be written + */ + private PqRegistryHash.Schedule scheduleFromGenesis(final Map entries) + throws Exception { + final List heights = new ArrayList<>(entries.keySet()); + heights.sort(Long::compare); + final StringBuilder sb = new StringBuilder("{\"config\":{\"pqRegistryHash\":["); + for (int i = 0; i < heights.size(); i++) { + if (i > 0) { + sb.append(','); + } + final long b = heights.get(i); + String h = entries.get(b); + if (!h.startsWith("0x")) { + h = "0x" + h; + } + sb.append("{\"block\":").append(b).append(",\"hash\":\"").append(h).append("\"}"); + } + sb.append("]}}"); + final Path p = tmp.resolve("genesis-schedule-" + heights.size() + "-" + heights.get(0) + ".json"); + Files.writeString(p, sb.toString()); + return PqRegistryHash.loadScheduleFromGenesis(p); + } + + private static void resetFalconSingleton() throws Exception { + final Field f = FalconSealSupport.class.getDeclaredField("instance"); + f.setAccessible(true); + f.set(null, null); + } +} diff --git a/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqRegistryRotationTest.java b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqRegistryRotationTest.java new file mode 100755 index 000000000..96debad37 --- /dev/null +++ b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqRegistryRotationTest.java @@ -0,0 +1,486 @@ +/* + * Copyright contributors to Besu / AERE Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * D-081: the Falcon signer registry has no usable rotation and no usable revocation. + * + *

WHAT IS MEASURED HERE, and why it is measured against the real classes rather than described. + * {@code config.pqRegistryHash} is a SCHEDULE of {block, hash} entries, and the A8 dossier states + * that "a later entry expresses a key rotation". This file asks whether that sentence survives + * contact with the code that enforces it. + * + *

The enforcement side is {@link PqRegistryHash#matchesAt} and, on the block path, {@code + * FalconSealSupport.registryBindingSatisfiedAt(height)}, which calls it. Both take exactly ONE + * loaded registry, and the node loads exactly one file ({@code aere.falcon.registry}). The entry the + * schedule makes active at a height decides which hash is required THERE. So after one rotation at + * H2 there are two intervals with two different required hashes, and one file can satisfy at most + * one of them. + * + *

The consequence is not cosmetic and it is not confined to the rotation moment. {@code + * PqRegistryBindingRule} is a DETACHED rule, so it runs on the header-download path, and {@code + * PqAnchorSyncModeGuard} refuses to start an armed node in anything but FULL sync. A node acquiring + * history therefore validates every height, including the interval before the rotation. Holding the + * post-rotation registry it is refused there; holding the pre-rotation registry it is refused at the + * head. There is no third choice. ONE rotation makes the chain permanently unjoinable. + * + *

This is the lesson Cosmos ADR-016 writes down explicitly: a rotation scheme has to keep the + * MAPPING FROM HEIGHT TO KEY SET, not only the current key set, or blocks signed under the old set + * stop being verifiable. Cosmos may bound that history by the unbonding period. We may not: chain + * 2800 has no unbonding period and a node syncing from genesis must verify every block that was ever + * produced, so every entry ever scheduled has to stay loadable forever. + * + *

{@code rotationDoesNotBrickHistory} and {@code revocationDoesNotBrickHistory} are the + * measurement. They FAIL while the defect is present and pass only when a node can be configured to + * satisfy the binding at EVERY scheduled height at once. The other tests are controls: they assert + * that the schedule really does express rotation and really does refuse a malformed one, so a + * failure of the two measurements cannot be blamed on the fixture. + */ +// The D-081 label is our internal finding id. It names a fact about this +// code, not anything outside it. +public class PqRegistryRotationTest { + + private static final long CHAIN_ID = 2800L; + + /** First binding height: the height the post-quantum registry is first enforced from. */ + private static final long H1 = 12_000_000L; + + /** Rotation height: from here the chain requires the SECOND registry. */ + private static final long H2 = 12_100_000L; + + /** Falcon-512 public key length as this registry format stores it (bare h polynomial). */ + private static final int PK_LENGTH = 896; + + /** The seven validators of chain 2800. */ + private static final int N = 7; + + /** + * One node configuration, expressed as the only question the consensus path ever asks it: does + * the registry material this node holds satisfy the binding the chain requires at this height? + * + *

It is an interface and not a Registry so that the measurement can be stated once and asked of + * every configuration a node can actually be put into. Today there is exactly one shape of answer, + * {@link #single}, because a node loads one file. A repair that lets a node hold the whole + * scheduled history adds a second shape here and the assertion below stops failing. Nothing in the + * assertion has to change, which is the point: the property is fixed, the capability is what moves. + */ + private interface NodeConfiguration { + boolean satisfiesAt(long height); + + String describe(); + } + + private static NodeConfiguration single( + final String name, final PqRegistryHash.Schedule schedule, final PqRegistryHash.Registry r) { + return new NodeConfiguration() { + @Override + public boolean satisfiesAt(final long height) { + return PqRegistryHash.matchesAt(schedule, r, height, CHAIN_ID); + } + + @Override + public String describe() { + return "node holding only registry " + name; + } + }; + } + + // --------------------------------------------------------------------------------------- + // Fixture. Two registries that differ in exactly one row, which is what both a rotation and a + // revocation look like on the wire: index 3 stops being the key it was. + // --------------------------------------------------------------------------------------- + + private static byte[] deterministicKey(final int index, final int generation) { + final byte[] pk = new byte[PK_LENGTH]; + for (int i = 0; i < pk.length; i++) { + pk[i] = (byte) ((i * 31) + (index * 7) + (generation * 101)); + } + return pk; + } + + private static byte[] address(final int index) { + final byte[] a = new byte[20]; + for (int i = 0; i < a.length; i++) { + a[i] = (byte) ((index * 17) + i); + } + return a; + } + + private static String hex(final byte[] b) { + final StringBuilder sb = new StringBuilder(b.length * 2); + for (final byte x : b) { + sb.append(String.format("%02x", x)); + } + return sb.toString(); + } + + /** + * A seven-row address-bound registry. {@code rotatedIndex} is the row whose key belongs to + * generation 2; every other row is generation 1. Passing -1 gives the untouched registry. + */ + private static Path writeRegistry(final Path dir, final String name, final int rotatedIndex) + throws IOException { + final StringBuilder sb = new StringBuilder(); + sb.append("count=").append(N).append('\n'); + for (int i = 0; i < N; i++) { + sb.append(i).append('=').append(hex(deterministicKey(i, i == rotatedIndex ? 2 : 1))).append('\n'); + sb.append(i).append(".addr=").append(hex(address(i))).append('\n'); + } + final Path p = dir.resolve(name); + Files.write(p, sb.toString().getBytes(StandardCharsets.UTF_8)); + return p; + } + + private static PqRegistryHash.Schedule scheduleOf(final String hashAtH1, final String hashAtH2) { + final String json = + "[{\"block\":" + + H1 + + ",\"hash\":\"0x" + + hashAtH1 + + "\"},{\"block\":" + + H2 + + ",\"hash\":\"0x" + + hashAtH2 + + "\"}]"; + final JsonNode node; + try { + node = new ObjectMapper().readTree(json); + } catch (final IOException e) { + throw new IllegalStateException(e); + } + return PqRegistryHash.parseSchedule(node, "D-081 fixture"); + } + + /** Every height at which the binding is enforced and could differ across the rotation. */ + private static List enforcedHeights() { + final List heights = new ArrayList<>(); + heights.add(H1); + heights.add(H1 + 1); + heights.add(H2 - 1); + heights.add(H2); + heights.add(H2 + 1); + return heights; + } + + /** + * The configurations a node can ACTUALLY be put into with the code as it stands. A repair that + * gives a node the whole scheduled history appends its configuration here; nothing else changes. + */ + private static List availableConfigurations( + final PqRegistryHash.Schedule schedule, + final PqRegistryHash.Registry before, + final PqRegistryHash.Registry after) { + final List all = new ArrayList<>(); + all.add(single("BEFORE", schedule, before)); + all.add(single("AFTER", schedule, after)); + all.add(wholeHistory(schedule, before, after)); + return all; + } + + /** + * D-081 repair: the node holds the WHOLE scheduled history and resolves by height. This + * configuration did not exist before the repair, which is why the assertion below could not be + * satisfied by any node at all. + */ + private static NodeConfiguration wholeHistory( + final PqRegistryHash.Schedule schedule, + final PqRegistryHash.Registry before, + final PqRegistryHash.Registry after) { + final List held = new ArrayList<>(); + held.add(before); + held.add(after); + final PqRegistryHash.RegistrySet set = PqRegistryHash.buildSet(schedule, held, CHAIN_ID); + return new NodeConfiguration() { + @Override + public boolean satisfiesAt(final long height) { + return PqRegistryHash.matchesAt(schedule, set, height, CHAIN_ID); + } + + @Override + public String describe() { + return "node holding the whole scheduled history (" + set + ")"; + } + }; + } + + // --------------------------------------------------------------------------------------- + // Controls. If these fail, the fixture is wrong and the measurements below mean nothing. + // --------------------------------------------------------------------------------------- + + @Test + public void controlTheScheduleReallyDoesExpressARotation(@TempDir final Path dir) + throws IOException { + final PqRegistryHash.Registry before = + PqRegistryHash.loadPropertiesRegistry(writeRegistry(dir, "before.properties", -1)); + final PqRegistryHash.Registry after = + PqRegistryHash.loadPropertiesRegistry(writeRegistry(dir, "after.properties", 3)); + + final String hashBefore = PqRegistryHash.hashV1(before, CHAIN_ID); + final String hashAfter = PqRegistryHash.hashV1(after, CHAIN_ID); + assertThat(hashBefore).isNotEqualTo(hashAfter); + + final PqRegistryHash.Schedule schedule = scheduleOf(hashBefore, hashAfter); + assertThat(schedule.enforced()).isTrue(); + assertThat(schedule.entries()).hasSize(2); + + // Below the first entry nothing is bound: the 11.8 million existing blocks stay untouched. + assertThat(PqRegistryHash.requiredHashAt(schedule, H1 - 1)).isEmpty(); + assertThat(PqRegistryHash.requiredHashAt(schedule, H1).orElseThrow().hash()).isEqualTo(hashBefore); + assertThat(PqRegistryHash.requiredHashAt(schedule, H2 - 1).orElseThrow().hash()) + .isEqualTo(hashBefore); + assertThat(PqRegistryHash.requiredHashAt(schedule, H2).orElseThrow().hash()).isEqualTo(hashAfter); + } + + @Test + public void controlAMalformedScheduleIsRefused(@TempDir final Path dir) throws IOException { + final PqRegistryHash.Registry before = + PqRegistryHash.loadPropertiesRegistry(writeRegistry(dir, "before.properties", -1)); + final String h = PqRegistryHash.hashV1(before, CHAIN_ID); + final String json = + "[{\"block\":" + H2 + ",\"hash\":\"0x" + h + "\"},{\"block\":" + H1 + ",\"hash\":\"0x" + h + "\"}]"; + final JsonNode node = new ObjectMapper().readTree(json); + assertThatThrownBy(() -> PqRegistryHash.parseSchedule(node, "D-081 fixture")) + .isInstanceOf(PqRegistryHash.RegistryConfigException.class) + .hasMessageContaining("STRICTLY INCREASING"); + } + + // --------------------------------------------------------------------------------------- + // THE MEASUREMENT. + // --------------------------------------------------------------------------------------- + + @Test + public void rotationDoesNotBrickHistory(@TempDir final Path dir) throws IOException { + final PqRegistryHash.Registry before = + PqRegistryHash.loadPropertiesRegistry(writeRegistry(dir, "before.properties", -1)); + final PqRegistryHash.Registry after = + PqRegistryHash.loadPropertiesRegistry(writeRegistry(dir, "after.properties", 3)); + final PqRegistryHash.Schedule schedule = + scheduleOf( + PqRegistryHash.hashV1(before, CHAIN_ID), PqRegistryHash.hashV1(after, CHAIN_ID)); + + final List heights = enforcedHeights(); + final List configurations = + availableConfigurations(schedule, before, after); + + final List report = new ArrayList<>(); + NodeConfiguration complete = null; + for (final NodeConfiguration c : configurations) { + final List refused = new ArrayList<>(); + for (final long h : heights) { + if (!c.satisfiesAt(h)) { + refused.add(h); + } + } + report.add(c.describe() + " is refused at " + refused); + if (refused.isEmpty()) { + complete = c; + } + } + + assertThat(complete) + .withFailMessage( + "ROTATION IS NOT USABLE: one scheduled rotation at height %d leaves NO node configuration " + + "that " + + "satisfies the registry binding at every enforced height. %s. A node that cannot " + + "satisfy the binding at a height cannot import a header at that height " + + "(PqRegistryBindingRule is DETACHED, so it runs on the header-download path), and " + + "PqAnchorSyncModeGuard forces FULL sync when the anchor is armed, so every node " + + "acquiring history must pass through the pre-rotation interval AND reach the head. " + + "Using the rotation mechanism once therefore makes the chain permanently " + + "unjoinable. A rotation scheme must keep the whole HEIGHT-TO-KEY-SET mapping " + + "loadable, not only the current entry.", + H2, + String.join("; ", report)) + .isNotNull(); + } + + @Test + public void revocationDoesNotBrickHistory(@TempDir final Path dir) throws IOException { + // Revocation is the same wire shape as rotation and is measured separately because it is the + // case with a deadline: a compromised Falcon key has to stop counting, and the operator has no + // reason to be able to re-sync afterwards only by luck. + final PqRegistryHash.Registry withCompromised = + PqRegistryHash.loadPropertiesRegistry(writeRegistry(dir, "compromised.properties", -1)); + final PqRegistryHash.Registry revoked = + PqRegistryHash.loadPropertiesRegistry(writeRegistry(dir, "revoked.properties", 5)); + final PqRegistryHash.Schedule schedule = + scheduleOf( + PqRegistryHash.hashV1(withCompromised, CHAIN_ID), + PqRegistryHash.hashV1(revoked, CHAIN_ID)); + + NodeConfiguration complete = null; + final List report = new ArrayList<>(); + for (final NodeConfiguration c : availableConfigurations(schedule, withCompromised, revoked)) { + boolean all = true; + final List refused = new ArrayList<>(); + for (final long h : enforcedHeights()) { + if (!c.satisfiesAt(h)) { + all = false; + refused.add(h); + } + } + report.add(c.describe() + " is refused at " + refused); + if (all) { + complete = c; + } + } + + assertThat(complete) + .withFailMessage( + "REVOCATION IS NOT USABLE: revoking one signer at height %d leaves NO node " + + "configuration that satisfies the binding at every enforced height. %s. The " + + "revocation is expressible and is not usable: performing it costs the ability to " + + "acquire the chain.", + H2, + String.join("; ", report)) + .isNotNull(); + } + + @Test + public void theKeySetInForceBelowTheRotationIsTheOldOne(@TempDir final Path dir) + throws IOException { + // Coverage alone would be satisfied by a set that answered every height with the same registry. + // This is the positive proof that the height actually selects: an old block resolves to the OLD + // key set, which is the whole reason the history is kept. + final PqRegistryHash.Registry before = + PqRegistryHash.loadPropertiesRegistry(writeRegistry(dir, "before.properties", -1)); + final PqRegistryHash.Registry after = + PqRegistryHash.loadPropertiesRegistry(writeRegistry(dir, "after.properties", 3)); + final PqRegistryHash.Schedule schedule = + scheduleOf(PqRegistryHash.hashV1(before, CHAIN_ID), PqRegistryHash.hashV1(after, CHAIN_ID)); + final List held = new ArrayList<>(); + held.add(before); + held.add(after); + final PqRegistryHash.RegistrySet set = PqRegistryHash.buildSet(schedule, held, CHAIN_ID); + + assertThat(set.coversWholeSchedule()).isTrue(); + assertThat(PqRegistryHash.registryAt(schedule, set, H1 - 1)).isEmpty(); + assertThat(PqRegistryHash.registryAt(schedule, set, H2 - 1).orElseThrow()).isSameAs(before); + assertThat(PqRegistryHash.registryAt(schedule, set, H2).orElseThrow()).isSameAs(after); + + // And the two really do differ at the rotated index, so "same registry everywhere" could not + // have produced the answers above. + assertThat(PqRegistryHash.fingerprint(before, 3)) + .isNotEqualTo(PqRegistryHash.fingerprint(after, 3)); + assertThat(PqRegistryHash.fingerprint(before, 0)).isEqualTo(PqRegistryHash.fingerprint(after, 0)); + } + + @Test + public void aMissingHistoricalRegistryIsNamedAndFailsClosed(@TempDir final Path dir) + throws IOException { + // The repair must not turn "I do not hold that registry" into "fine". An uncovered entry is + // named by height and refuses at exactly the heights it governs, and nowhere else. + final PqRegistryHash.Registry before = + PqRegistryHash.loadPropertiesRegistry(writeRegistry(dir, "before.properties", -1)); + final PqRegistryHash.Registry after = + PqRegistryHash.loadPropertiesRegistry(writeRegistry(dir, "after.properties", 3)); + final PqRegistryHash.Schedule schedule = + scheduleOf(PqRegistryHash.hashV1(before, CHAIN_ID), PqRegistryHash.hashV1(after, CHAIN_ID)); + + final List onlyAfter = new ArrayList<>(); + onlyAfter.add(after); + final PqRegistryHash.RegistrySet partial = + PqRegistryHash.buildSet(schedule, onlyAfter, CHAIN_ID); + + assertThat(partial.coversWholeSchedule()).isFalse(); + assertThat(partial.uncoveredEntryBlocks()).containsExactly(H1); + assertThat(PqRegistryHash.matchesAt(schedule, partial, H1, CHAIN_ID)).isFalse(); + assertThat(PqRegistryHash.matchesAt(schedule, partial, H2 - 1, CHAIN_ID)).isFalse(); + assertThat(PqRegistryHash.matchesAt(schedule, partial, H2, CHAIN_ID)).isTrue(); + // Below the schedule nothing is enforced, so an incomplete set still leaves history alone. + assertThat(PqRegistryHash.matchesAt(schedule, partial, H1 - 1, CHAIN_ID)).isTrue(); + // And a null set is refused wherever a binding is active, never passed over. + assertThat(PqRegistryHash.matchesAt(schedule, (PqRegistryHash.RegistrySet) null, H1, CHAIN_ID)) + .isFalse(); + } + + @Test + public void theOperatorConfigurationStringProducesACoveringSet(@TempDir final Path dir) + throws IOException { + // WHY THIS EXISTS, and it is a gap the other six leave open on purpose-by-omission. Every one of + // them reaches the covering configuration by calling PqRegistryHash.buildSet with a list of + // Registry objects the test built itself. No operator can do that. What an operator can do is + // write a comma-separated list of FILE PATHS into aere.falcon.registry.history, and the node + // turns that string into the same set through parseRegistryPaths + loadAuto + // (FalconSealSupport.verifyRegistryBindingOrAbort, the D-081 block). If that route were broken + // the other six would still be green and the capability would still not be usable, which is the + // exact shape of "a green result in a reduced environment is true and worthless". + // + // So this measurement starts from the STRING and ends at the same property the measurement + // tests assert: satisfied at every enforced height. + final Path beforePath = writeRegistry(dir, "before.properties", -1); + final Path afterPath = writeRegistry(dir, "after.properties", 3); + final PqRegistryHash.Registry before = PqRegistryHash.loadPropertiesRegistry(beforePath); + final PqRegistryHash.Registry after = PqRegistryHash.loadPropertiesRegistry(afterPath); + final PqRegistryHash.Schedule schedule = + scheduleOf(PqRegistryHash.hashV1(before, CHAIN_ID), PqRegistryHash.hashV1(after, CHAIN_ID)); + + // Written the way an operator writes it: one string, comma separated, with the sloppy spacing + // a unit file actually carries. The node's own file is the FIRST element of the held list, so + // the string names the OTHER one; here both are named, which is also legal and must not + // double-count. + final String configured = " " + beforePath + " , " + afterPath + " ,"; + final List paths = PqRegistryHash.parseRegistryPaths(configured); + assertThat(paths).hasSize(2); + + final PqRegistryHash.RegistrySet set = PqRegistryHash.loadSet(schedule, paths, CHAIN_ID); + assertThat(set.coversWholeSchedule()).isTrue(); + assertThat(set.uncoveredEntryBlocks()).isEmpty(); + + final List refused = new ArrayList<>(); + for (final long h : enforcedHeights()) { + if (!PqRegistryHash.matchesAt(schedule, set, h, CHAIN_ID)) { + refused.add(h); + } + } + assertThat(refused) + .withFailMessage( + "ROTATION IS NOT USABLE on the route an operator can actually take: the history list %s " + + "parses " + + "and loads, and the resulting set is still refused at %s. The library can express " + + "the whole height-to-key-set mapping but the configuration string cannot reach " + + "it, so the rotation remains expressible and not usable.", + configured, refused) + .isEmpty(); + + // Positive proof that the string, not luck, did the selecting: below the rotation the OLD file + // is in force, at and above it the NEW one. + assertThat(PqRegistryHash.registryAt(schedule, set, H2 - 1).orElseThrow()) + .isNotSameAs(PqRegistryHash.registryAt(schedule, set, H2).orElseThrow()); + + // And the failure direction on the same route: a history string that names only one of the two + // files leaves the other entry uncovered, named by height, and refusing exactly there. + final PqRegistryHash.RegistrySet partial = + PqRegistryHash.loadSet( + schedule, PqRegistryHash.parseRegistryPaths(afterPath.toString()), CHAIN_ID); + assertThat(partial.uncoveredEntryBlocks()).containsExactly(H1); + assertThat(PqRegistryHash.matchesAt(schedule, partial, H1, CHAIN_ID)).isFalse(); + } +} diff --git a/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqSchemeScheduleTest.java b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqSchemeScheduleTest.java new file mode 100755 index 000000000..41e03e825 --- /dev/null +++ b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqSchemeScheduleTest.java @@ -0,0 +1,127 @@ +/* AERE crypto-agility, step 5 proofs. The D-147 control is the one that matters: the dangerous + * step hides at the END of the schedule, and the gate must walk all of it. */ +package org.hyperledger.besu.consensus.common.bft; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.security.SecureRandom; +import java.util.Properties; +import java.util.Set; + +import org.apache.tuweni.bytes.Bytes; +import org.hyperledger.besu.crypto.SecureRandomProvider; +import org.junit.jupiter.api.Test; + +class PqSchemeScheduleTest { + + private static final String FALCON = "falcon-512"; + private static final String SLHDSA = "slh-dsa-128s"; + + private final SecureRandom random = SecureRandomProvider.createSecureRandom(); + + // ------------------------------------------------------------------ parse + schemesAt + + @Test + void schedulesParseAndAnswerByHeight() { + final PqSchemeSchedule orar = + PqSchemeSchedule.parse("100:falcon-512,200:falcon-512+slh-dsa-128s"); + assertThat(orar.schemesAt(99)).isEmpty(); // inainte de prima treapta: v2 nearmat + assertThat(orar.schemesAt(100)).containsExactlyInAnyOrder(FALCON); // exact pe granita + assertThat(orar.schemesAt(150)).containsExactlyInAnyOrder(FALCON); + assertThat(orar.schemesAt(200)).containsExactlyInAnyOrder(FALCON, SLHDSA); // hibridul + assertThat(orar.schemesAt(1_000_000)).containsExactlyInAnyOrder(FALCON, SLHDSA); + } + + // ------------------------------------------------------------------ refuzuri de parse + + @Test + void unknownSchemeAnywhereRefusesTheWholeSchedule() { + assertThatThrownBy(() -> PqSchemeSchedule.parse("100:falcon-512,200:dilithium-notyet")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dilithium-notyet"); + } + + @Test + void nonIncreasingHeightsRefuse() { + assertThatThrownBy(() -> PqSchemeSchedule.parse("200:falcon-512,100:falcon-512")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("strictly increase"); + assertThatThrownBy(() -> PqSchemeSchedule.parse("200:falcon-512,200:slh-dsa-128s")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("strictly increase"); + } + + @Test + void emptyAndMalformedStepsRefuse() { + assertThatThrownBy(() -> PqSchemeSchedule.parse("")).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> PqSchemeSchedule.parse("100")).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> PqSchemeSchedule.parse("abc:falcon-512")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> PqSchemeSchedule.parse("100:")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> PqSchemeSchedule.parse("100:falcon-512+falcon-512")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("repeats"); + assertThatThrownBy(() -> PqSchemeSchedule.parse("-5:falcon-512")) + .isInstanceOf(IllegalArgumentException.class); + } + + // ------------------------------------------------------------- poarta de armare (D-147) + + private HybridSignerRegistry registruCuAcoperire(final int falconi, final int slhuri) { + final Properties p = new Properties(); + final int count = Math.max(falconi, Math.max(slhuri, 1)); + p.setProperty("formatVersion", "hybrid-1"); + p.setProperty("chainId", "2800"); + p.setProperty("count", String.valueOf(count)); + for (int i = 0; i < count; i++) { + p.setProperty(i + ".addr", "0x" + String.format("%040x", 0xB0 + i)); + // fiecare index primeste macar o cheie; acoperirea per schema e controlata mai jos + if (i < falconi) { + p.setProperty(i + ".key." + FALCON, + Bytes.wrap(SealSchemes.FALCON_512.generate(random).publicRegistryForm()).toHexString()); + } + if (i < slhuri) { + p.setProperty(i + ".key." + SLHDSA, + Bytes.wrap(SealSchemes.SLH_DSA_128S.generate(random).publicRegistryForm()).toHexString()); + } + if (i >= falconi && i >= slhuri) { + p.setProperty(i + ".key." + FALCON, + Bytes.wrap(SealSchemes.FALCON_512.generate(random).publicRegistryForm()).toHexString()); + } + } + return HybridSignerRegistry.fromProperties(p, "test"); + } + + @Test + void armabilityGateWalksTheWholeScheduleNotJustTheFirstStep() { + // the registry: 3 validators with Falcon, only 1 with SLH-DSA + final HybridSignerRegistry reg = registruCuAcoperire(3, 1); + // treapta PERICULOASA e ULTIMA: hibridul cere SLH-DSA cu acoperire 1 < K=3 + final PqSchemeSchedule orar = + PqSchemeSchedule.parse("100:falcon-512,999999:falcon-512+slh-dsa-128s"); + final var refusal = orar.firstUnsatisfied(reg, 3); + assertThat(refusal).isPresent(); + assertThat(refusal.get()).contains("999999").contains(SLHDSA).contains("covers only 1"); + } + + @Test + void armabilityPassesWhenEverySchemeHasCoverage() { + final HybridSignerRegistry reg = registruCuAcoperire(3, 3); + final PqSchemeSchedule orar = + PqSchemeSchedule.parse("100:falcon-512,200:falcon-512+slh-dsa-128s"); + assertThat(orar.firstUnsatisfied(reg, 3)).isEmpty(); + // and the same gate's negative control: an impossible threshold must refuse + assertThat(orar.firstUnsatisfied(reg, 4)).isPresent(); + } + + @Test + void beforeTheFirstStepMeansLegacyNotSomeDefaultScheme() { + final PqSchemeSchedule orar = PqSchemeSchedule.parse("500:falcon-512"); + assertThat(orar.schemesAt(0)).isEmpty(); + assertThat(orar.schemesAt(499)).isEmpty(); + assertThat(orar.steps()).hasSize(1); + assertThat(orar.steps().get(0).schemeIds()).isEqualTo(Set.of(FALCON)); + } +} diff --git a/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqSealPersistenceTest.java b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqSealPersistenceTest.java new file mode 100755 index 000000000..f0ffc4cab --- /dev/null +++ b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqSealPersistenceTest.java @@ -0,0 +1,652 @@ +/* + * Copyright contributors to Besu / AERE Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.withSettings; + +import org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer; +import org.hyperledger.besu.consensus.common.validator.ValidatorProvider; +import org.hyperledger.besu.crypto.SecureRandomProvider; +import org.hyperledger.besu.datatypes.Address; +import org.hyperledger.besu.ethereum.ProtocolContext; +import org.hyperledger.besu.ethereum.core.BlockHeader; +import org.hyperledger.besu.ethereum.core.BlockHeaderTestFixture; + +import java.lang.reflect.Field; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.OptionalInt; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +import org.apache.tuweni.bytes.Bytes; +import org.apache.tuweni.bytes.Bytes32; +import org.bouncycastle.crypto.digests.KeccakDigest; +import org.bouncycastle.pqc.crypto.falcon.FalconPrivateKeyParameters; +import org.bouncycastle.pqc.crypto.falcon.FalconSigner; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.quality.Strictness; + +/** + * D-141. THE SECOND HALF OF THE FLEET-RESTART CHAIN DEATH: the heard seals themselves. + * + *

MEASURED ON A NETWORK FIRST, NOT ASSUMED. With the anchor armed at K>0, a SIMULTANEOUS + * restart of all seven validators kills the chain permanently (rehearsal + * repetitie-activare-2026-08-05, isolated chain 330858). The FIRST half of that deadlock was the + * registry, repaired the same day: it now activates at start-up from chain-head state, and all + * seven nodes reported "anchor activation at STARTUP from head state: SUCCEEDED". The chain died + * anyway. The refusal only changed shape, from "registry address-bound=false" to "registry + * address-bound=TRUE ... Heard 0 seal(s)", frozen 150 s then 298 s. + * + *

THE SECOND CIRCLE. The Falcon seals over M(head) travel on nothing but the Commit messages of + * the head block, and those are never replayed after a restart. They exist nowhere else: the head's + * own header carries a certificate over its PARENT, not over itself. So every node came back holding + * zero seals, none could reach K, none could propose, and therefore none ever sent another Commit + * for another node to hear. Seals come from Commits, Commits come from proposals, proposals need + * seals. + * + *

WHAT THIS CLASS MEASURES, one test per link, with the causal chain driven in BOTH directions so + * that "refuses" is never satisfied by a producer that always refuses: + * + *

    + *
  1. {@link #restartWithNoFileIsTheMeasuredDeadlockAndTheFileIsTheWayOut()} - the whole thing + * end to end: the same node, the same head, the same K. Without the file the producer throws; + * with the file restored it produces a K-seal certificate whose digest matches. This is the + * chain death and its exit, in one method. + *
  2. {@link #aForgedSealInTheFileIsRejectedAtReadAndNeverEntersTheCache()} - THE SECURITY + * PROPERTY, and the test the build-time negative control turns RED. Three shapes of forgery in + * one file: a signature over the wrong message, random bytes, and a genuine seal re-labelled + * under someone else's index. None survives, and the genuine ones alongside them do. + *
  3. {@link #aCorruptFileDoesNotStopTheNode()} - truncated, random, empty, a directory where the + * file should be. Every one of them yields an empty cache and no exception. + *
  4. {@link #aFileFromAnotherHeightOrAnotherChainIsIgnored()} - the binding checks, before a + * single signature is verified. + *
  5. {@link #theWriteIsAtomicUnderAConcurrentReader()} - a reader hammering the file across 120 + * writes never observes a partial file. + *
  6. {@link #thePathComesFromTheDataDirectory()} - the path is derived, never configured. + *
  7. {@link #whatOneWriteCostsAgainstTheBlockInterval()} - the price of doing this on the + * consensus thread, as a number rather than as a hope. + *
+ * + *

NOT MEASURED here, and named so it is not read as covered: that seven live nodes recover from a + * real simultaneous restart with a binary built from this tree. That needs the rehearsal network and + * is separate evidence. This class measures every decision that recovery depends on. + */ +// The D-141 label is our internal finding id. It names a fact about this +// code, not anything outside it. +public class PqSealPersistenceTest { + + /** Anchor activation height H. */ + private static final long H = 1_000L; + + /** Seal-attachment height, comfortably below H. */ + private static final long ATTACH = 900L; + + /** Height from which the staged threshold K is in force. */ + private static final long K_AT = H + 10L; + + /** The founder's decision of 2026-08-05: N=7 stays, and K=3 is the value with full margin. */ + private static final int K = 3; + + private static final int N = 7; + + private static final long CHAIN_ID = 2_800L; + + /** Measured block interval on the live chain, in milliseconds. */ + private static final long BLOCK_INTERVAL_MS = 523L; + + @TempDir private Path tmp; + + /** Stands in for the node's data directory, which is where the real path comes from. */ + private Path dataDirectory; + + private final List

validators = new ArrayList<>(); + private final List privateKeys = new ArrayList<>(); + private BlockHeader head; + private ProtocolContext context; + private BftExtraData base; + + @BeforeEach + public void setUp() throws Exception { + dataDirectory = Files.createDirectories(tmp.resolve("besu-data")); + + // AERE D-146 (2026-08-06): v2, proof-bound, bound at H. See PqV2Fixture. + final KeccakDigest kd = new KeccakDigest(256); + final StringBuilder manifest = + new StringBuilder("{\"config\":{\"aereFalconRegistry\":{") + .append(PqV2Fixture.manifestHeader(N, CHAIN_ID, H)); + for (int i = 0; i < N; i++) { + privateKeys.add(PqV2Fixture.privateKey(i)); + validators.add(PqV2Fixture.address(i)); + final byte[] anchoredRow = PqV2Fixture.anchorPreimageRow(i); + kd.update(anchoredRow, 0, anchoredRow.length); + manifest.append(',').append(PqV2Fixture.manifestEntry(i, N, CHAIN_ID, H)); + } + final byte[] anchoredHash = new byte[32]; + kd.doFinal(anchoredHash, 0); + manifest + .append("}},\"alloc\":{\"0000000000000000000000000000000000000fa1\":{\"storage\":{\"0x") + .append("0".repeat(64)) + .append("\":\"0x") + .append(Bytes.wrap(anchoredHash).toUnprefixedHexString()) + .append("\"}}}}"); + final Path genesisPath = tmp.resolve("genesis-registry.json"); + Files.writeString(genesisPath, manifest.toString()); + System.setProperty("aere.falcon.genesis", genesisPath.toAbsolutePath().toString()); + System.setProperty("aere.falcon.attachBlock", Long.toString(ATTACH)); + + resetFalconSingleton(); + PqSealCache.instance().disablePersistence(); + PqSealCache.instance().clear(); + PqAnchorProducer.useConfigForTesting( + new PqAnchorConfig(CHAIN_ID, H, Map.of(H, 0, K_AT, K), OptionalInt.empty(), false)); + + head = new BlockHeaderTestFixture().number(K_AT + 20L).buildHeader(); + context = contextWith(validators); + base = + new BftExtraData( + Bytes32.ZERO, + Collections.emptyList(), + Optional.empty(), + 0, + validators, + Collections.emptyList()); + } + + @AfterEach + public void tearDown() throws Exception { + System.clearProperty("aere.falcon.genesis"); + System.clearProperty("aere.falcon.attachBlock"); + PqSealCache.instance().disablePersistence(); + PqSealCache.instance().clear(); + PqAnchorProducer.useConfigForTesting(null); + resetFalconSingleton(); + } + + // ------------------------------------------------------------------------------------------- + // 1. The chain death, and its exit, in one method. + // ------------------------------------------------------------------------------------------- + + @Test + public void restartWithNoFileIsTheMeasuredDeadlockAndTheFileIsTheWayOut() { + // Fixture control: without a genuinely armed registry every assertion below would be about + // nothing at all. + assertThat(FalconSealSupport.instance().genesisAnchored()).isTrue(); + assertThat(FalconSealSupport.instance().addressBound()).isTrue(); + assertThat(FalconSealSupport.instance().registrySize()).isEqualTo(N); + + // --- BEFORE THE RESTART. The node is running, persistence is on, and it hears K Commits for + // its head exactly the way QbftRound.pqCacheHeardSeals feeds them in. + PqSealCache.instance().enablePersistence(dataDirectory, CHAIN_ID); + final Path file = PqSealStore.fileIn(dataDirectory); + for (int i = 0; i < K; i++) { + PqSealCache.instance().record(head.getNumber(), head.getHash(), List.of(genuineSeal(i))); + } + assertThat(file).exists(); + assertThat(PqSealCache.instance().sealCount(head.getHash())).isEqualTo(K); + + // --- THE RESTART. A fresh process: the map is gone, the file is not. Nothing else changes. + PqSealCache.instance().clear(); + assertThat(PqSealCache.instance().sealCount(head.getHash())) + .describedAs("the in-memory map does not survive a restart, which is the whole problem") + .isZero(); + + // --- THE DEADLOCK, as measured on the seven-node network. Without the file this is terminal: + // no proposal means no Commit, and no Commit means no seal, for ever. + assertThatThrownBy(() -> PqAnchorProducer.apply(base, head, context)) + .describedAs( + "the measured chain death: a restarted node holds no seals over M(head), so it cannot " + + "assemble a certificate and cannot propose") + .isInstanceOf(PqAnchorNotReadyException.class) + .hasMessageContaining("Heard 0 seal(s)"); + + // --- THE REPAIR. One read, every seal re-verified, and the same producer on the same inputs + // now produces a certificate. This is the ONLY thing the start-up code adds. + final int restored = + PqSealCache.instance() + .restoreFromDisk( + head.getNumber(), head.getHash(), PqSignerRegistry.falconSealSupport()); + assertThat(restored).isEqualTo(K); + assertThat(PqSealCache.instance().sealCount(head.getHash())).isEqualTo(K); + + final BftExtraData produced = PqAnchorProducer.apply(base, head, context); + assertThat(produced.getFalconSeals()) + .describedAs("the restarted node can propose again, carrying a K=%d certificate", K) + .hasSize(K); + assertThat(PqAnchor.hasStrictlyIncreasingIndices(produced.getFalconSeals())).isTrue(); + assertThat(produced.getVanityData()) + .describedAs("and the anchor digest is the one the validator side will recompute") + .isEqualTo( + PqAnchor.anchorDigest( + CHAIN_ID, + head.getNumber(), + head.getHash().getBytes(), + PqAnchor.sortedByIndex(produced.getFalconSeals()))); + } + + // ------------------------------------------------------------------------------------------- + // 2. THE SECURITY PROPERTY. This is the test the build-time negative control turns RED. + // ------------------------------------------------------------------------------------------- + + /** + * Persisting seals is only defensible because a seal is SELF-AUTHENTICATING: it is re-verified at + * read, against the anchored registry, over M rebuilt from the head this process just loaded. If + * that were not so, the file would be exactly defect A8 in another coat - state believed because + * it sits in a file a node can be pointed at. + * + *

Three shapes of forgery are in the one file, because "a forged seal" is not one thing: + * + *

    + *
  1. index 3, a REAL Falcon signature by validator 3, but over another block's M. This is the + * replay an attacker with access to any past Commit traffic actually has. + *
  2. index 4, random bytes of exactly the right length. The cheapest forgery there is. + *
  3. index 5, validator 0's GENUINE signature over the right M, re-labelled as index 5. This + * one is the reason index alone can never be the check: the bytes are valid, the claim is + * not. + *
+ */ + @Test + public void aForgedSealInTheFileIsRejectedAtReadAndNeverEntersTheCache() throws Exception { + final List genuine = List.of(genuineSeal(0), genuineSeal(1), genuineSeal(2)); + + final Bytes32 anotherBlocksMessage = + PqAnchor.commitMessage(CHAIN_ID, head.getNumber() - 1L, Bytes32.leftPad(Bytes.of(9))); + final byte[] randomBytes = new byte[genuine.get(0).getSignature().size()]; + SecureRandomProvider.createSecureRandom().nextBytes(randomBytes); + + final List forged = + List.of( + new FalconSeal(3, Bytes.wrap(falconSign(privateKeys.get(3), anotherBlocksMessage))), + new FalconSeal(4, Bytes.wrap(randomBytes)), + new FalconSeal(5, genuine.get(0).getSignature())); + + final List all = new ArrayList<>(genuine); + all.addAll(forged); + final Path file = PqSealStore.fileIn(dataDirectory); + PqSealStore.writeAtomically( + file, + PqSealStore.encode(CHAIN_ID, head.getNumber(), head.getHash().getBytes(), all)); + assertThat(file).exists(); + + PqSealCache.instance().enablePersistence(dataDirectory, CHAIN_ID); + final int restored = + PqSealCache.instance() + .restoreFromDisk( + head.getNumber(), head.getHash(), PqSignerRegistry.falconSealSupport()); + + assertThat(restored) + .describedAs( + "THE LOAD-BEARING ASSERTION. Six seals were in the file and only the three genuine ones " + + "may come out. Delete the verify() call in PqSealStore and this line goes red, " + + "which is exactly what the build-time negative control proves.") + .isEqualTo(3); + + final List inCache = + PqSealCache.instance().sealsFor(head.getNumber(), head.getHash()); + assertThat(inCache).hasSize(3); + assertThat(inCache.stream().map(FalconSeal::getValidatorIndex)) + .describedAs("no forged index may reach the cache at all") + .containsExactly(0, 1, 2); + assertThat(inCache).containsExactlyInAnyOrderElementsOf(genuine); + + // And the genuine ones are not merely present, they are usable: the producer, which verifies + // again at selection, accepts exactly these three. Without this half the test would be + // satisfied by a reader that rejected everything. + final BftExtraData produced = PqAnchorProducer.apply(base, head, context); + assertThat(produced.getFalconSeals()).hasSize(K).containsExactlyElementsOf(genuine); + } + + // ------------------------------------------------------------------------------------------- + // 3. A corrupt file must never be able to stop a node. + // ------------------------------------------------------------------------------------------- + + @Test + public void aCorruptFileDoesNotStopTheNode() throws Exception { + final Path file = PqSealStore.fileIn(dataDirectory); + final byte[] good = + PqSealStore.encode( + CHAIN_ID, + head.getNumber(), + head.getHash().getBytes(), + List.of(genuineSeal(0), genuineSeal(1), genuineSeal(2))); + PqSealCache.instance().enablePersistence(dataDirectory, CHAIN_ID); + + // (a) no file at all: the ordinary first start. + Files.deleteIfExists(file); + assertRestoresNothingWithoutThrowing("no file at all"); + + // (b) truncated halfway: a write interrupted by a machine that lost power. The atomic rename + // is what stops this from happening, and this is what would happen if it did anyway. + Files.write(file, Arrays.copyOf(good, good.length / 2)); + assertRestoresNothingWithoutThrowing("truncated file"); + + // (c) random bytes: a wrong file copied over it, or a corrupt sector. + final byte[] noise = new byte[good.length]; + SecureRandomProvider.createSecureRandom().nextBytes(noise); + Files.write(file, noise); + assertRestoresNothingWithoutThrowing("random bytes"); + + // (d) empty file. + Files.write(file, new byte[0]); + assertRestoresNothingWithoutThrowing("empty file"); + + // (e) valid RLP, wrong domain: a file written for something else entirely. + Files.write(file, Bytes.fromHexString("0xc50102030405").toArrayUnsafe()); + assertRestoresNothingWithoutThrowing("valid RLP, wrong shape"); + + // (f) a DIRECTORY where the file should be. Not exotic: a mount gone wrong does this. + Files.deleteIfExists(file); + Files.createDirectory(file); + assertRestoresNothingWithoutThrowing("a directory in place of the file"); + Files.delete(file); + + // And after all of that the node is still a working node: a good file still restores. + Files.write(file, good); + assertThat( + PqSealCache.instance() + .restoreFromDisk( + head.getNumber(), head.getHash(), PqSignerRegistry.falconSealSupport())) + .describedAs( + "positive control: without this line every assertion above would be satisfied by a " + + "reader that can never read anything") + .isEqualTo(3); + } + + private void assertRestoresNothingWithoutThrowing(final String what) { + PqSealCache.instance().clear(); + assertThatCode( + () -> + assertThat( + PqSealCache.instance() + .restoreFromDisk( + head.getNumber(), + head.getHash(), + PqSignerRegistry.falconSealSupport())) + .describedAs("%s must restore nothing", what) + .isZero()) + .describedAs("%s must not throw: a node that cannot read the file is a node with none", what) + .doesNotThrowAnyException(); + assertThat(PqSealCache.instance().sealCount(head.getHash())).isZero(); + } + + // ------------------------------------------------------------------------------------------- + // 4. The binding checks, made before any signature is verified. + // ------------------------------------------------------------------------------------------- + + @Test + public void aFileFromAnotherHeightOrAnotherChainIsIgnored() throws Exception { + final Path file = PqSealStore.fileIn(dataDirectory); + final List seals = List.of(genuineSeal(0), genuineSeal(1), genuineSeal(2)); + PqSealCache.instance().enablePersistence(dataDirectory, CHAIN_ID); + + // Same seals, but the file claims another height. They cannot help the proposer of head+1. + PqSealStore.writeAtomically( + file, + PqSealStore.encode(CHAIN_ID, head.getNumber() - 1L, head.getHash().getBytes(), seals)); + assertRestoresNothingWithoutThrowing("a file from another height"); + + // Same seals, another block hash at the right height: a fork of the same number. + PqSealStore.writeAtomically( + file, + PqSealStore.encode( + CHAIN_ID, head.getNumber(), Bytes32.leftPad(Bytes.of(7)), seals)); + assertRestoresNothingWithoutThrowing("a file for another block at the same height"); + + // Another chain running the same binaries and possibly the same Falcon keys. + PqSealStore.writeAtomically( + file, + PqSealStore.encode(442_807L, head.getNumber(), head.getHash().getBytes(), seals)); + assertRestoresNothingWithoutThrowing("a file from another chain"); + + // Positive control for this method: the same three seals, correctly bound, do restore. + PqSealStore.writeAtomically( + file, PqSealStore.encode(CHAIN_ID, head.getNumber(), head.getHash().getBytes(), seals)); + PqSealCache.instance().clear(); + assertThat( + PqSealCache.instance() + .restoreFromDisk( + head.getNumber(), head.getHash(), PqSignerRegistry.falconSealSupport())) + .isEqualTo(3); + } + + // ------------------------------------------------------------------------------------------- + // 5. Atomicity, measured against a reader rather than asserted from the API docs. + // ------------------------------------------------------------------------------------------- + + @Test + public void theWriteIsAtomicUnderAConcurrentReader() throws Exception { + final Path file = PqSealStore.fileIn(dataDirectory); + final List seals = new ArrayList<>(); + for (int i = 0; i < N; i++) { + seals.add(genuineSeal(i)); + } + PqSealStore.writeAtomically( + file, PqSealStore.encode(CHAIN_ID, head.getNumber(), head.getHash().getBytes(), seals)); + + final AtomicBoolean stop = new AtomicBoolean(false); + final AtomicInteger reads = new AtomicInteger(); + final AtomicInteger partialReads = new AtomicInteger(); + final Thread reader = + new Thread( + () -> { + while (!stop.get()) { + final List got = + PqSealStore.readVerified( + file, + CHAIN_ID, + head.getNumber(), + head.getHash(), + PqSignerRegistry.falconSealSupport()); + reads.incrementAndGet(); + if (got.isEmpty()) { + partialReads.incrementAndGet(); + } + } + }); + reader.setDaemon(true); + reader.start(); + + for (int round = 0; round < 120; round++) { + final List subset = seals.subList(0, 1 + (round % N)); + PqSealStore.writeAtomically( + file, + PqSealStore.encode(CHAIN_ID, head.getNumber(), head.getHash().getBytes(), subset)); + } + stop.set(true); + reader.join(30_000L); + + assertThat(reads) + .describedAs("fixture control: the reader must actually have run") + .hasValueGreaterThan(0); + assertThat(partialReads) + .describedAs( + "%s reads across 120 writes and not one saw a half-written file. Temp plus rename is " + + "the reason; writing in place would have produced partial reads here.", + reads.get()) + .hasValue(0); + assertThat(dataDirectory.resolve(PqSealStore.TEMP_FILE_NAME)) + .describedAs("the temporary file must not be left behind") + .doesNotExist(); + } + + // ------------------------------------------------------------------------------------------- + // 6. The path is DERIVED from the data directory, never separately configured. + // ------------------------------------------------------------------------------------------- + + @Test + public void thePathComesFromTheDataDirectory() { + assertThat(PqSealStore.fileIn(dataDirectory)) + .isEqualTo(dataDirectory.resolve(PqSealStore.FILE_NAME)); + + PqSealCache.instance().enablePersistence(dataDirectory, CHAIN_ID); + assertThat(PqSealCache.instance().persistenceFile()) + .isEqualTo(dataDirectory.resolve(PqSealStore.FILE_NAME)); + + final Path other = tmp.resolve("another-node"); + PqSealCache.instance().enablePersistence(other, CHAIN_ID); + assertThat(PqSealCache.instance().persistenceFile()) + .describedAs("two nodes on one machine never share the file") + .isEqualTo(other.resolve(PqSealStore.FILE_NAME)) + .isNotEqualTo(dataDirectory.resolve(PqSealStore.FILE_NAME)); + + // A null data directory leaves persistence off rather than inventing a path. + PqSealCache.instance().disablePersistence(); + PqSealCache.instance().enablePersistence(null, CHAIN_ID); + assertThat(PqSealCache.instance().persistenceFile()).isNull(); + PqSealCache.instance().record(head.getNumber(), head.getHash(), List.of(genuineSeal(0))); + assertThat(PqSealCache.instance().sealCount(head.getHash())) + .describedAs("with persistence off the cache still works exactly as before") + .isEqualTo(1); + } + + // ------------------------------------------------------------------------------------------- + // 7. The price, as a number. + // ------------------------------------------------------------------------------------------- + + /** + * The write happens on the consensus thread, once per Commit heard, so its cost is a real + * property of this change and not a footnote. Seven seals is the whole fleet. + */ + @Test + public void whatOneWriteCostsAgainstTheBlockInterval() throws Exception { + final Path file = PqSealStore.fileIn(dataDirectory); + final List seals = new ArrayList<>(); + for (int i = 0; i < N; i++) { + seals.add(genuineSeal(i)); + } + final byte[] payload = + PqSealStore.encode(CHAIN_ID, head.getNumber(), head.getHash().getBytes(), seals); + + final int rounds = 100; + final long[] micros = new long[rounds]; + for (int i = 0; i < rounds; i++) { + final long t0 = System.nanoTime(); + PqSealStore.writeAtomically(file, payload); + micros[i] = (System.nanoTime() - t0) / 1_000L; + } + Arrays.sort(micros); + final long median = micros[rounds / 2]; + final long p95 = micros[(int) (rounds * 0.95)]; + final long worst = micros[rounds - 1]; + + // Printed so the number lands in the test XML and can be quoted as a measurement rather than + // remembered as an impression. + System.out.println( + "AERE PERSISTENTA-SIGILII MEASURED: payload=" + + payload.length + + " bytes for " + + N + + " seals; write median=" + + median + + " us, p95=" + + p95 + + " us, worst=" + + worst + + " us over " + + rounds + + " writes; fsync=" + + !"false".equalsIgnoreCase(System.getProperty(PqSealStore.PROPERTY_FSYNC)) + + "; block interval=" + + BLOCK_INTERVAL_MS + + " ms."); + + assertThat(payload.length) + .describedAs("seven Falcon-512 seals plus the binding fields") + .isLessThan(16 * 1024); + assertThat(median) + .describedAs( + "one write must cost far less than one block interval, or persisting on the consensus " + + "thread would be trading a restart deadlock for a liveness cost") + .isLessThan(BLOCK_INTERVAL_MS * 1_000L / 10L); + } + + // ------------------------------------------------------------------------------------------- + // Helpers. + // ------------------------------------------------------------------------------------------- + + private FalconSeal genuineSeal(final int index) { + final Bytes32 m = PqAnchor.commitMessage(CHAIN_ID, head.getNumber(), head.getHash().getBytes()); + return new FalconSeal(index, Bytes.wrap(falconSign(privateKeys.get(index), m))); + } + + private static byte[] falconSign(final FalconPrivateKeyParameters key, final Bytes32 m) { + final FalconSigner signer = new FalconSigner(); + signer.init(true, key); + return signer.generateSignature(m.toArray()); + } + + private static ProtocolContext contextWith(final Collection
validatorSet) { + final ValidatorProvider validatorProvider = + mock(ValidatorProvider.class, withSettings().strictness(Strictness.LENIENT)); + when(validatorProvider.getValidatorsForBlock(any())).thenReturn(validatorSet); + when(validatorProvider.getValidatorsAfterBlock(any())).thenReturn(validatorSet); + final BftContext bftContext = + mock(BftContext.class, withSettings().strictness(Strictness.LENIENT)); + when(bftContext.getValidatorProvider()).thenReturn(validatorProvider); + when(bftContext.as(any())).thenReturn(bftContext); + return new ProtocolContext.Builder().withConsensusContext(bftContext).build(); + } + + private static void resetFalconSingleton() throws Exception { + final Field f = FalconSealSupport.class.getDeclaredField("instance"); + f.setAccessible(true); + f.set(null, null); + } + + /** + * D-330 (2026-09-03, testnet 28001): the main file followed the highest height HEARD, which was a + * proposal that never imported (168032), so after a fleet-wide restart no node held the seals of + * the head (168031). The previous slot keeps them one height longer. + */ + @Test + public void theSealsOfTheHeadSurviveTheStoreMovingToALaterHeightThatNeverImported() { + PqSealCache.instance().enablePersistence(dataDirectory, CHAIN_ID); + for (int i = 0; i < K; i++) { + PqSealCache.instance().record(head.getNumber(), head.getHash(), List.of(genuineSeal(i))); + } + // the fleet now hears commits for a NEXT block that will never import + final BlockHeader next = new BlockHeaderTestFixture().number(head.getNumber() + 1L).buildHeader(); + final Bytes32 mNext = PqAnchor.commitMessage(CHAIN_ID, next.getNumber(), next.getHash().getBytes()); + final FalconSigner signer = new FalconSigner(); + signer.init(true, privateKeys.get(0)); + PqSealCache.instance() + .record(next.getNumber(), next.getHash(), + List.of(new FalconSeal(0, Bytes.wrap(signer.generateSignature(mNext.toArray()))))); + assertThat(PqSealStore.previousFileIn(dataDirectory)).describedAs("the previous slot exists after the rotation").exists(); + // restart: memory gone, the main file holds the never-imported height + PqSealCache.instance().clear(); + final int restored = + PqSealCache.instance() + .restoreFromDisk(head.getNumber(), head.getHash(), PqSignerRegistry.falconSealSupport()); + assertThat(restored).describedAs("the head's seals come back from the previous slot").isEqualTo(K); + assertThat(PqAnchorProducer.apply(base, head, context).getFalconSeals()).hasSize(K); + } +} diff --git a/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqSignedHeightTest.java b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqSignedHeightTest.java new file mode 100755 index 000000000..8122465d0 --- /dev/null +++ b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqSignedHeightTest.java @@ -0,0 +1,402 @@ +/* + * Copyright contributors to Besu. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.IOException; +import java.lang.reflect.Field; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.OptionalInt; + +import org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * AERE D-B (2026-08-06). THE SILENT DEFERRAL, and it is the worst of the three because nothing shows + * it. + * + *

WHAT IS SUPPOSED TO BE TRUE. Every row of a v2 registry carries two signatures - a Falcon + * possession proof and an ECDSA claim by that validator's own consensus key - and BOTH sign the bind + * height ({@code PqRegistryBinding.bindingPreimage}). A registry put in force from block B is + * therefore every one of those validators stating that B is the height they agreed to. + * + *

WHAT WAS ACTUALLY TRUE UNTIL THIS FILE. {@code bindHeight} was never compared with the {@code + * block} of the schedule entry that puts the registry in force. Not anywhere. The two numbers had + * been in the same lexical scope since D-081 and were never put on the same expression. + * + *

WHY THE HASH DOES NOT CATCH IT, which is the part that makes this invisible rather than merely + * missing. {@code bindHeight} is INSIDE the v2 pre-image, so it is covered by the hash - and that is + * exactly why the comparison looks unnecessary. But the enforcement is hash against hash: the + * required hash comes from genesis and the computed hash comes from the file, and a file signed for + * 40 and scheduled from 70 reproduces its own hash perfectly. Both sides agree, and neither side + * ever mentions a height. {@link #theHashMATCHESWhichIsWhyNobodyEverSawThis()} measures precisely + * that, and it is the assertion that makes the rest of this file mean something. + * + *

MEASURED ON A NETWORK OF SEVEN, 2026-08-06, scenario C2: the arming height moved from 40 to 70 + * with the proofs left signed for 40. Seven of seven nodes started, the chain ran to head 95, + * agreement was 7 of 7, ZERO errors and ZERO refusals - while the offline tool, on the same files, + * said RED. The node and the tool disagreed and nothing put them face to face. + * + *

WHAT IT BUYS SOMEBODY WHO SHOULD NOT HAVE IT: moving the activation day costs 14 fresh + * validator signatures if this is checked and ZERO if it is not. + * + *

THE RULE CHOSEN, and why it is the correct one rather than merely a working one: + * + *

    + *
  • A proof-bound registry that reproduces the hash of schedule entry E must declare {@code + * bindHeight == E.block}. Enforced ({@code AERE-PQC-REG-BIND-08}). Both numbers come from + * genesis and the file, and genesis is the one document all seven nodes hold byte-identically, + * so this comparison is backed by the same agreement consensus already rests on. + *
  • NOT refused: a registry enforced from a FUTURE height. The comparison is against the + * ENTRY'S block, never against the chain head, so an entry scheduled a year out is untouched - + * {@link #aRegistryScheduledFarInTheFutureIsNOTRefused()}. + *
  • NOT refused: a schedule with several epochs. Each entry is compared against the registry + * that reproduces ITS hash, one pair at a time - {@link #twoEpochsEachSignedForItsOwnHeightAreFINE()}. + *
  • NOT touched: v1 registries. They carry no signatures over any height, {@code bindHeight} is + * -1, and the condition is gated on {@code proofBound()} - {@link #aV1RegistryIsNOTJudgedByThisRuleAtAll()}. + *
  • An entry no held file reproduces stays UNCOVERED, which is a different condition with a + * different message. It is not compared - {@link #anUncoveredEntryIsNotAMisboundOne()}. + *
+ * + *

AND THE HALF THAT CANNOT BE ENFORCED, written as a limitation and not as a to-do. {@code + * aere.pq.anchorBlock} is a system property set per node; it is in no genesis. The seven validators' + * agreement on an arming height therefore CANNOT be enforced by code running on one node - only + * DETECTED, which is {@code AERE-PQC-REG-BIND-09} and {@link + * #armingAtAHeightNobodySignedForIsREFUSEDLocally()}. Enforcement would require the height to move + * into genesis, which is a change to the chain and not to this class. + * + *

KEYS. Every key here is generated in memory by {@link PqV2Fixture} and never written outside a + * JUnit temporary directory. + */ +public class PqSignedHeightTest { + + /** The height the fleet actually signed its registry for. */ + private static final long SIGNED = 40L; + + /** The height somebody moved the activation to, without asking anybody to sign again. */ + private static final long MOVED = 70L; + + private static final int N = 7; + + private static final long CHAIN_ID = 220_878L; + + @TempDir private Path tmp; + + private Path signedForForty; + private String hashOfSignedForForty; + + @BeforeEach + public void setUp() throws Exception { + signedForForty = writeRegistry("signed-for-40.properties", SIGNED); + hashOfSignedForForty = + PqRegistryHash.hashFor(PqRegistryHash.loadAuto(signedForForty), CHAIN_ID); + System.setProperty("aere.falcon.registry", signedForForty.toAbsolutePath().toString()); + resetFalconSingleton(); + PqAnchorProducer.useConfigForTesting(null); + } + + @AfterEach + public void tearDown() throws Exception { + System.clearProperty("aere.falcon.registry"); + System.clearProperty(FalconSealSupport.PROPERTY_REGISTRY_HISTORY); + resetFalconSingleton(); + PqAnchorProducer.useConfigForTesting(null); + } + + // ------------------------------------------------------------------------------------------- + // 0. WHY NOBODY SAW IT. The hash agrees. If this assertion ever fails, the defect described in + // this file did not exist and everything below is theatre. + // ------------------------------------------------------------------------------------------- + + @Test + public void theHashMATCHESWhichIsWhyNobodyEverSawThis() throws Exception { + final PqRegistryHash.Registry r = PqRegistryHash.loadAuto(signedForForty); + assertThat(r.proofBound()).isTrue(); + assertThat(r.bindHeight()).isEqualTo(SIGNED); + + // The schedule puts this file in force from 70. The file was signed for 40. The hash the + // schedule requires and the hash the file computes are THE SAME NUMBER, because bindHeight sits + // inside the pre-image and travels with the file. Hash-against-hash can never see this. + final PqRegistryHash.Schedule moved = schedule(Map.of(MOVED, hashOfSignedForForty)); + assertThat(PqRegistryHash.requiredHashAt(moved, MOVED).orElseThrow().hash()) + .describedAs( + "the required hash and the computed hash agree perfectly while the heights differ by " + + "30 blocks. That agreement IS the defect") + .isEqualToIgnoringCase(PqRegistryHash.hashFor(r, CHAIN_ID)); + } + + // ------------------------------------------------------------------------------------------- + // 1. THE MEASUREMENT, repaired: the two numbers are now put side by side and the node refuses. + // ------------------------------------------------------------------------------------------- + + @Test + public void aRegistryScheduledAtAHeightItWasNotSignedForIsREFUSED() throws Exception { + final FalconSealSupport pqc = FalconSealSupport.instance(); + + assertThatThrownBy( + () -> + pqc.verifyRegistryBindingOrAbort( + MOVED + 25L, CHAIN_ID, schedule(Map.of(MOVED, hashOfSignedForForty)))) + .isInstanceOf(PqRegistryHash.RegistryConfigException.class) + .hasMessageContaining("AERE-PQC-REG-BIND-08") + // The message must carry BOTH numbers and name the field, or it is useless at 3am. + .hasMessageContaining("bindHeight") + .hasMessageContaining("in force FROM BLOCK " + MOVED) + .hasMessageContaining("bindHeight=" + SIGNED) + .hasMessageContaining("14 fresh validator signatures"); + } + + @Test + public void andItIsRefusedEVENWHENTheMovedHeightIsStillAhead() throws Exception { + // The whole hazard of a deferral is that it is invisible until the day arrives. Catching it only + // once the chain has reached the height would be catching it at the halt, which is the thing + // being repaired. Chain head is far below the moved entry here. + final FalconSealSupport pqc = FalconSealSupport.instance(); + assertThatThrownBy( + () -> + pqc.verifyRegistryBindingOrAbort( + 0L, CHAIN_ID, schedule(Map.of(MOVED, hashOfSignedForForty)))) + .isInstanceOf(PqRegistryHash.RegistryConfigException.class) + .hasMessageContaining("AERE-PQC-REG-BIND-08"); + } + + // ------------------------------------------------------------------------------------------- + // 2. NEGATIVE CONTROLS. The rule must refuse the moved height and NOTHING ELSE. + // ------------------------------------------------------------------------------------------- + + @Test + public void theSameFileAtTheHeightItWASSignedForStarts() throws Exception { + final FalconSealSupport pqc = FalconSealSupport.instance(); + assertThat( + pqc.verifyRegistryBindingOrAbort( + SIGNED + 25L, CHAIN_ID, schedule(Map.of(SIGNED, hashOfSignedForForty)))) + .describedAs( + "identical file, identical hash, identical everything except the one number this rule " + + "is about. If this refuses too, the rule is a wedge and not a guard") + .isEqualTo(PqRegistryHash.GateState.MATCH); + } + + @Test + public void aRegistryScheduledFarInTheFutureIsNOTRefused() throws Exception { + // A registry staged for an activation a year out: signed for that height, scheduled at that + // height, chain head nowhere near it. The comparison is entry-block against bindHeight and never + // against the chain head, so this is untouched. + final long future = 12_000_000L; + final Path staged = writeRegistry("staged.properties", future); + final String h = PqRegistryHash.hashFor(PqRegistryHash.loadAuto(staged), CHAIN_ID); + System.setProperty("aere.falcon.registry", staged.toAbsolutePath().toString()); + resetFalconSingleton(); + final FalconSealSupport pqc = FalconSealSupport.instance(); + + assertThat(pqc.verifyRegistryBindingOrAbort(1_000L, CHAIN_ID, schedule(Map.of(future, h)))) + .isEqualTo(PqRegistryHash.GateState.NOT_ENFORCED_BELOW_FIRST_HEIGHT); + } + + @Test + public void twoEpochsEachSignedForItsOwnHeightAreFINE() throws Exception { + final Path second = writeRegistry("epoch-two.properties", MOVED); + final String hSecond = PqRegistryHash.hashFor(PqRegistryHash.loadAuto(second), CHAIN_ID); + System.setProperty( + FalconSealSupport.PROPERTY_REGISTRY_HISTORY, second.toAbsolutePath().toString()); + final FalconSealSupport pqc = FalconSealSupport.instance(); + + final Map both = new LinkedHashMap<>(); + both.put(SIGNED, hashOfSignedForForty); + both.put(MOVED, hSecond); + + assertThat(pqc.verifyRegistryBindingOrAbort(MOVED + 25L, CHAIN_ID, schedule(both))) + .describedAs( + "a multi-epoch schedule is the normal shape after a rotation. Each entry is judged " + + "against the file that reproduces ITS hash, so several epochs are several " + + "independent comparisons and not one blanket") + .isEqualTo(PqRegistryHash.GateState.MATCH); + } + + @Test + public void aV1RegistryIsNOTJudgedByThisRuleAtAll() throws Exception { + // No proofs, therefore no signature over any height, therefore no height to disagree with. + // bindHeight is -1 here and would mismatch every entry there is; the gate on proofBound() is + // what keeps a v1 fleet from being refused by a rule about signatures it never made. + final Path v1 = writeV1Registry("legacy.properties"); + final PqRegistryHash.Registry r = PqRegistryHash.loadAuto(v1); + assertThat(r.proofBound()).isFalse(); + assertThat(r.bindHeight()).isNegative(); + + final String h = PqRegistryHash.hashFor(r, CHAIN_ID); + final PqRegistryHash.RegistrySet set = + PqRegistryHash.buildSet(schedule(Map.of(MOVED, h)), List.of(r), CHAIN_ID); + + assertThat(set.misbound()) + .describedAs("a v1 registry can never be misbound, because it never bound itself to a height") + .isEmpty(); + assertThat(set.forEntryBlock(MOVED)).isSameAs(r); + } + + @Test + public void anUncoveredEntryIsNotAMisboundOne() throws Exception { + // An entry whose hash nothing held reproduces is a MISSING file, which an operator legitimately + // has. It must not be reported as a signed-height mismatch: different mistake, different fix. + final Map both = new LinkedHashMap<>(); + both.put(SIGNED, hashOfSignedForForty); + both.put(MOVED + 1_000L, "cd".repeat(32)); + final PqRegistryHash.RegistrySet set = + PqRegistryHash.buildSet( + schedule(both), List.of(PqRegistryHash.loadAuto(signedForForty)), CHAIN_ID); + + assertThat(set.misbound()).isEmpty(); + assertThat(set.uncoveredEntryBlocks()).containsExactly(MOVED + 1_000L); + } + + // ------------------------------------------------------------------------------------------- + // 3. THE LOCAL HALF. Arming at a height nobody signed for: detected, and honestly labelled. + // ------------------------------------------------------------------------------------------- + + @Test + public void armingAtAHeightNobodySignedForIsREFUSEDLocally() throws Exception { + // The schedule is coherent - the file is signed for 40 and scheduled from 40 - and the ANCHOR + // has been moved to 70 on this node alone. Nothing in genesis constrains aere.pq.anchorBlock, so + // this can only be caught where both values are in one hand, which is here. + PqAnchorProducer.useConfigForTesting( + new PqAnchorConfig(CHAIN_ID, MOVED, Map.of(MOVED, 3), OptionalInt.empty(), false)); + resetFalconSingleton(); + final FalconSealSupport pqc = FalconSealSupport.instance(); + + assertThatThrownBy( + () -> + pqc.verifyRegistryBindingOrAbort( + SIGNED + 5L, CHAIN_ID, schedule(Map.of(SIGNED, hashOfSignedForForty)))) + .isInstanceOf(PqRegistryHash.RegistryConfigException.class) + .hasMessageContaining("AERE-PQC-REG-BIND-09") + .hasMessageContaining("aere.pq.anchorBlock = " + MOVED) + .hasMessageContaining("config.pqRegistryHash heights = [" + SIGNED + "]") + // The honest limitation has to be IN the message, or an operator will read this as a + // consensus guarantee it is not. + .hasMessageContaining("DETECTION on this node only") + .hasMessageContaining("on every node and in the same change"); + } + + @Test + public void armingExactlyAtAScheduledHeightSTARTS() throws Exception { + PqAnchorProducer.useConfigForTesting( + new PqAnchorConfig(CHAIN_ID, SIGNED, Map.of(SIGNED, 3), OptionalInt.empty(), false)); + resetFalconSingleton(); + final FalconSealSupport pqc = FalconSealSupport.instance(); + + assertThat( + pqc.verifyRegistryBindingOrAbort( + SIGNED + 5L, CHAIN_ID, schedule(Map.of(SIGNED, hashOfSignedForForty)))) + .describedAs( + "the positive half, and it is the expensive one: a gate that refuses every armed node " + + "is not a gate") + .isEqualTo(PqRegistryHash.GateState.MATCH); + } + + @Test + public void anUNARMEDNodeIsNeverAskedTheQuestion() throws Exception { + // Chain 2800 as it stands: aere.pq.anchorBlock unset. The arming-height comparison must not + // even be reachable, whatever the schedule says. + PqAnchorProducer.useConfigForTesting(PqAnchorConfig.never(CHAIN_ID)); + resetFalconSingleton(); + final FalconSealSupport pqc = FalconSealSupport.instance(); + + assertThatCode( + () -> + pqc.verifyRegistryBindingOrAbort( + SIGNED + 5L, CHAIN_ID, schedule(Map.of(SIGNED, hashOfSignedForForty)))) + .doesNotThrowAnyException(); + } + + // ------------------------------------------------------------------------------------------- + // Helpers. + // ------------------------------------------------------------------------------------------- + + private Path writeRegistry(final String name, final long bindHeight) throws IOException { + final StringBuilder b = new StringBuilder(); + b.append("formatVersion=").append(PqRegistryBinding.FORMAT_VERSION).append('\n'); + b.append("chainId=").append(CHAIN_ID).append('\n'); + b.append("bindHeight=").append(bindHeight).append('\n'); + b.append("count=").append(N).append('\n'); + for (int i = 0; i < N; i++) { + b.append(i).append('=').append(hex(PqV2Fixture.publicKey(i))).append('\n'); + b.append(i).append(".addr=").append(PqV2Fixture.address(i).toHexString()).append('\n'); + b.append(i).append(".pop=").append(PqV2Fixture.popHex(CHAIN_ID, bindHeight, N, i)).append('\n'); + b.append(i) + .append(".claim=") + .append(PqV2Fixture.claimHex(CHAIN_ID, bindHeight, N, i)) + .append('\n'); + } + final Path f = tmp.resolve(name); + Files.writeString(f, b.toString(), StandardCharsets.UTF_8); + return f; + } + + private Path writeV1Registry(final String name) throws IOException { + final StringBuilder b = new StringBuilder(); + b.append("count=").append(N).append('\n'); + for (int i = 0; i < N; i++) { + b.append(i).append('=').append(hex(PqV2Fixture.publicKey(i))).append('\n'); + b.append(i).append(".addr=").append(PqV2Fixture.address(i).toHexString()).append('\n'); + } + final Path f = tmp.resolve(name); + Files.writeString(f, b.toString(), StandardCharsets.UTF_8); + return f; + } + + private PqRegistryHash.Schedule schedule(final Map entries) throws IOException { + final List heights = new ArrayList<>(entries.keySet()); + heights.sort(Long::compare); + final StringBuilder sb = new StringBuilder("{\"config\":{\"pqRegistryHash\":["); + for (int i = 0; i < heights.size(); i++) { + if (i > 0) { + sb.append(','); + } + sb.append("{\"block\":") + .append(heights.get(i)) + .append(",\"hash\":\"0x") + .append(entries.get(heights.get(i))) + .append("\"}"); + } + sb.append("]}}"); + final Path p = tmp.resolve("genesis-db-" + heights.size() + "-" + heights.get(0) + ".json"); + Files.writeString(p, sb.toString(), StandardCharsets.UTF_8); + return PqRegistryHash.loadScheduleFromGenesis(p); + } + + private static String hex(final byte[] b) { + final StringBuilder s = new StringBuilder(b.length * 2); + for (final byte x : b) { + s.append(Character.forDigit((x >> 4) & 0xf, 16)).append(Character.forDigit(x & 0xf, 16)); + } + return s.toString(); + } + + private static void resetFalconSingleton() throws Exception { + final Field f = FalconSealSupport.class.getDeclaredField("instance"); + f.setAccessible(true); + f.set(null, null); + } +} diff --git a/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqStartupHistoryTest.java b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqStartupHistoryTest.java new file mode 100755 index 000000000..4826779da --- /dev/null +++ b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqStartupHistoryTest.java @@ -0,0 +1,324 @@ +/* + * Copyright contributors to Besu. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.IOException; +import java.lang.reflect.Field; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * AERE D-A (2026-08-06). AFTER THE FIRST ROTATION, NO NODE COULD BE RESTARTED WITH ITS OWN CORRECT + * CONFIGURATION. + * + *

WHAT WAS MEASURED, and it was measured twice: once during the rotation rehearsal on a network + * of seven, and again on 2026-08-06 with the chain head at 2554. A node configured exactly as the + * runbook says - the genesis manifest as the registry it signs with, every rotation registry named + * in {@code aere.falcon.registry.history} - refused to start with {@code AERE-PQC-REG-MISMATCH-01}. + * Nothing was wrong with it. + * + *

THE DEFECT WAS THE ORDER OF TWO BLOCKS OF CODE. {@code + * FalconSealSupport.verifyRegistryBindingOrAbort} loaded ONE registry, the primary, and handed it to + * the guard. The history list was read FORTY-ONE LINES FURTHER DOWN, to build the height-resolved + * set D-081 introduced. So the refusal was thrown before the code that knew the answer had run. The + * guard was not wrong about what it compared; it was never shown the other files. + * + *

WHY IT BITES EXACTLY AFTER A ROTATION AND NEVER BEFORE. The primary registry is the genesis + * manifest, and genesis does not change. A rotation adds a SECOND entry to {@code + * config.pqRegistryHash} and a second file, which can only arrive through the history property. From + * the rotation height onwards the entry in force names the second file, the guard compares the + * first, and every restart fails. Before the rotation there is one entry and one file and the two + * paths are indistinguishable - which is why this survived every test the tree had. + * + *

THE NEGATIVE CONTROLS ARE THE POINT. {@link #withNoHistoryConfiguredTheRefusalIsSTILLTHERE()} + * and {@link #aHistoryFileThatCoversNothingDoesNotRescueAnything()} fail if the repair is "stop + * refusing" rather than "consult the history". A guard that has stopped refusing is not a repaired + * guard, and this pair is what tells the two apart. + * + *

ALSO CLOSED HERE: a finding in its own right. Before this file, {@code + * AERE-PQC-REG-MISMATCH-01}, {@code AERE-PQC-REG-MISMATCH-02} and {@code GateState} appeared in NO + * test anywhere in the tree (searched over every src/test of every consensus module on + * 2026-08-06). The + * refusal to start had only ever been exercised by running real nodes. + * + *

KEYS. Every key here is generated in memory by {@link PqV2Fixture} and never written outside a + * JUnit temporary directory. + */ +public class PqStartupHistoryTest { + + /** Where the chain first requires a registry: epoch one, the genesis manifest. */ + private static final long H1 = 600L; + + /** The rotation height: from here the chain requires the second registry. */ + private static final long H2 = 2_000L; + + private static final int N = 7; + + private static final long CHAIN_ID = 220_878L; + + @TempDir private Path tmp; + + private Path epochOne; + private Path epochTwo; + private String hashOne; + private String hashTwo; + + @BeforeEach + public void setUp() throws Exception { + // Two epochs of the SAME fleet, re-signed at two heights. That is the whole difference between + // the files and it is deliberate: it makes them two distinct registries with two distinct + // hashes, and it removes every other variable, so a guard that gets this wrong cannot be + // excused by a changed key. + epochOne = writeRegistry("epoch-one.properties", H1); + epochTwo = writeRegistry("epoch-two.properties", H2); + hashOne = PqRegistryHash.hashFor(PqRegistryHash.loadAuto(epochOne), CHAIN_ID); + hashTwo = PqRegistryHash.hashFor(PqRegistryHash.loadAuto(epochTwo), CHAIN_ID); + assertThat(hashOne).isNotEqualTo(hashTwo); + + // A test that asserts what happens WITHOUT an anchor height must establish that itself, not + // inherit it from whoever ran before. Measured 2026-08-11: under its previous name this class + // sorted before every class that sets aere.pq.anchorBlock, so it passed for the right result and + // the wrong reason. The real leak is a remembered configuration, not the property, and it is + // fixed where it leaks; this stays as the belt to that pair of braces. + System.clearProperty("aere.pq.anchorBlock"); + System.clearProperty("aere.pq.anchorMinSeals"); + System.setProperty("aere.falcon.registry", epochOne.toAbsolutePath().toString()); + resetFalconSingleton(); + } + + @AfterEach + public void tearDown() throws Exception { + System.clearProperty("aere.falcon.registry"); + System.clearProperty(FalconSealSupport.PROPERTY_REGISTRY_HISTORY); + resetFalconSingleton(); + } + + // ------------------------------------------------------------------------------------------- + // 0. The fixture. Without this a green run below could mean the guard was never armed at all. + // ------------------------------------------------------------------------------------------- + + @Test + public void baselineBeforeTheRotationTheOneFileIsENOUGH() throws Exception { + final FalconSealSupport pqc = FalconSealSupport.instance(); + // One entry, one file, head below the rotation: the configuration every node ran before the + // rotation existed. This is the state in which the defect is INVISIBLE. + assertThat(pqc.verifyRegistryBindingOrAbort(H1 + 10L, CHAIN_ID, schedule(Map.of(H1, hashOne)))) + .isEqualTo(PqRegistryHash.GateState.MATCH); + } + + // ------------------------------------------------------------------------------------------- + // 1. THE MEASUREMENT. The rotated fleet, configured exactly as its runbook says. + // ------------------------------------------------------------------------------------------- + + @Test + public void afterTheRotationTheNODESTARTSBecauseTheGuardREADSTheHistory() throws Exception { + System.setProperty( + FalconSealSupport.PROPERTY_REGISTRY_HISTORY, epochTwo.toAbsolutePath().toString()); + final FalconSealSupport pqc = FalconSealSupport.instance(); + + final Map both = new LinkedHashMap<>(); + both.put(H1, hashOne); + both.put(H2, hashTwo); + + assertThat(pqc.verifyRegistryBindingOrAbort(H2 + 554L, CHAIN_ID, schedule(both))) + .describedAs( + "head above the rotation, the required registry held as HISTORY, the primary still the " + + "genesis manifest. This is the correct configuration and until 2026-08-06 it " + + "refused to start with AERE-PQC-REG-MISMATCH-01, because the guard was handed the " + + "primary alone and the history list was read 41 lines later") + .isEqualTo(PqRegistryHash.GateState.MATCH); + } + + @Test + public void andTheWHOLESCHEDULEIsCoveredNotOnlyTheHead() throws Exception { + System.setProperty( + FalconSealSupport.PROPERTY_REGISTRY_HISTORY, epochTwo.toAbsolutePath().toString()); + final FalconSealSupport pqc = FalconSealSupport.instance(); + final Map both = new LinkedHashMap<>(); + both.put(H1, hashOne); + both.put(H2, hashTwo); + pqc.verifyRegistryBindingOrAbort(H2 + 554L, CHAIN_ID, schedule(both)); + + // Starting is not the whole story: a node that starts and then refuses every header below the + // rotation has traded one halt for another. Both intervals must answer. + assertThat(pqc.registryBindingSatisfiedAt(H1)).isTrue(); + assertThat(pqc.registryBindingSatisfiedAt(H2 - 1L)).isTrue(); + assertThat(pqc.registryBindingSatisfiedAt(H2)).isTrue(); + assertThat(pqc.registryBindingSatisfiedAt(H2 + 554L)).isTrue(); + } + + // ------------------------------------------------------------------------------------------- + // 2. NEGATIVE CONTROL. The repair is "read the history", not "stop refusing". + // ------------------------------------------------------------------------------------------- + + @Test + public void withNoHistoryConfiguredTheRefusalIsSTILLTHERE() throws Exception { + final FalconSealSupport pqc = FalconSealSupport.instance(); + final Map both = new LinkedHashMap<>(); + both.put(H1, hashOne); + both.put(H2, hashTwo); + + assertThatThrownBy(() -> pqc.verifyRegistryBindingOrAbort(H2 + 554L, CHAIN_ID, schedule(both))) + .describedAs( + "a node that genuinely does not hold the rotation registry must still refuse to start. " + + "If this passes, the repair removed the guard instead of feeding it") + .isInstanceOf(PqRegistryHash.RegistryConfigException.class) + .hasMessageContaining("AERE-PQC-REG-MISMATCH-01") + .hasMessageContaining("REGISTRY FILES THIS NODE HOLDS: 1") + .hasMessageContaining("aere.falcon.registry.history"); + } + + @Test + public void aHistoryFileThatCoversNothingDoesNotRescueAnything() throws Exception { + // A third file, signed for a height the schedule never mentions. It is a real, loadable, + // proof-bound registry; it simply answers for no interval of this chain. + final Path unrelated = writeRegistry("unrelated.properties", H2 + 999L); + System.setProperty( + FalconSealSupport.PROPERTY_REGISTRY_HISTORY, unrelated.toAbsolutePath().toString()); + final FalconSealSupport pqc = FalconSealSupport.instance(); + final Map both = new LinkedHashMap<>(); + both.put(H1, hashOne); + both.put(H2, hashTwo); + + assertThatThrownBy(() -> pqc.verifyRegistryBindingOrAbort(H2 + 554L, CHAIN_ID, schedule(both))) + .describedAs( + "holding MORE files is not the property that matters; holding the RIGHT one is. A " + + "repair that counted files instead of matching hashes would pass the test above " + + "and fail here") + .isInstanceOf(PqRegistryHash.RegistryConfigException.class) + .hasMessageContaining("AERE-PQC-REG-MISMATCH-01") + .hasMessageContaining("REGISTRY FILES THIS NODE HOLDS: 2"); + } + + @Test + public void aNodeWithNOREGISTRYATALLIsRefusedAndTheCodeIsTheOtherONE() throws Exception { + System.clearProperty("aere.falcon.registry"); + resetFalconSingleton(); + final FalconSealSupport pqc = FalconSealSupport.instance(); + + assertThatThrownBy( + () -> pqc.verifyRegistryBindingOrAbort(H1 + 10L, CHAIN_ID, schedule(Map.of(H1, hashOne)))) + .describedAs( + "no registry at all is a different mistake from the wrong registry, and it has always " + + "had its own code. Until this file, neither code was asserted anywhere") + .isInstanceOf(PqRegistryHash.RegistryConfigException.class) + .hasMessageContaining("AERE-PQC-REG-MISMATCH-02") + .hasMessageContaining("NO REGISTRY AT ALL"); + } + + // ------------------------------------------------------------------------------------------- + // 3. The states that are NOT refusals. A guard that only ever refuses is a wedge in a costume. + // ------------------------------------------------------------------------------------------- + + @Test + public void belowTheFirstScheduledHeightNothingIsEnforcedAndTheNodeIsTOLDItIsStaged() + throws Exception { + System.setProperty( + FalconSealSupport.PROPERTY_REGISTRY_HISTORY, epochTwo.toAbsolutePath().toString()); + final FalconSealSupport pqc = FalconSealSupport.instance(); + assertThat(pqc.verifyRegistryBindingOrAbort(H1 - 100L, CHAIN_ID, schedule(Map.of(H1, hashOne)))) + .isEqualTo(PqRegistryHash.GateState.NOT_ENFORCED_BELOW_FIRST_HEIGHT); + } + + @Test + public void withNoScheduleAtAllNOTHINGIsEnforcedWhichIsChain2800Today() throws Exception { + final FalconSealSupport pqc = FalconSealSupport.instance(); + assertThat( + pqc.verifyRegistryBindingOrAbort( + H2 + 554L, CHAIN_ID, PqRegistryHash.emptySchedule())) + .describedAs( + "config.pqRegistryHash is in no genesis this fleet runs. This is the branch every " + + "live node takes, and it must stay first and unconditional") + .isEqualTo(PqRegistryHash.GateState.NOT_ENFORCED_NO_SCHEDULE); + } + + // ------------------------------------------------------------------------------------------- + // Helpers. + // ------------------------------------------------------------------------------------------- + + private Path writeRegistry(final String name, final long bindHeight) throws IOException { + final StringBuilder b = new StringBuilder(); + b.append("formatVersion=").append(PqRegistryBinding.FORMAT_VERSION).append('\n'); + b.append("chainId=").append(CHAIN_ID).append('\n'); + b.append("bindHeight=").append(bindHeight).append('\n'); + b.append("count=").append(N).append('\n'); + for (int i = 0; i < N; i++) { + b.append(i).append('=').append(hex(PqV2Fixture.publicKey(i))).append('\n'); + b.append(i) + .append(".addr=") + .append(PqV2Fixture.address(i).toHexString()) + .append('\n'); + b.append(i).append(".pop=").append(PqV2Fixture.popHex(CHAIN_ID, bindHeight, N, i)).append('\n'); + b.append(i) + .append(".claim=") + .append(PqV2Fixture.claimHex(CHAIN_ID, bindHeight, N, i)) + .append('\n'); + } + final Path f = tmp.resolve(name); + Files.writeString(f, b.toString(), StandardCharsets.UTF_8); + return f; + } + + /** + * Build a schedule the way a node really gets one: written into a genesis file and parsed back. + * Constructing the object directly would skip the parser, where the strictly-increasing rule is. + */ + private PqRegistryHash.Schedule schedule(final Map entries) throws IOException { + final List heights = new ArrayList<>(entries.keySet()); + heights.sort(Long::compare); + final StringBuilder sb = new StringBuilder("{\"config\":{\"pqRegistryHash\":["); + for (int i = 0; i < heights.size(); i++) { + if (i > 0) { + sb.append(','); + } + sb.append("{\"block\":") + .append(heights.get(i)) + .append(",\"hash\":\"0x") + .append(entries.get(heights.get(i))) + .append("\"}"); + } + sb.append("]}}"); + final Path p = tmp.resolve("genesis-sched-" + heights.size() + "-" + heights.get(0) + ".json"); + Files.writeString(p, sb.toString(), StandardCharsets.UTF_8); + return PqRegistryHash.loadScheduleFromGenesis(p); + } + + private static String hex(final byte[] b) { + final StringBuilder s = new StringBuilder(b.length * 2); + for (final byte x : b) { + s.append(Character.forDigit((x >> 4) & 0xf, 16)).append(Character.forDigit(x & 0xf, 16)); + } + return s.toString(); + } + + private static void resetFalconSingleton() throws Exception { + final Field f = FalconSealSupport.class.getDeclaredField("instance"); + f.setAccessible(true); + f.set(null, null); + } +} diff --git a/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqV2Fixture.java b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqV2Fixture.java new file mode 100755 index 000000000..4fb9529b3 --- /dev/null +++ b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqV2Fixture.java @@ -0,0 +1,232 @@ +/* + * Copyright contributors to Besu. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +import org.hyperledger.besu.crypto.KeyPair; +import org.hyperledger.besu.crypto.SECPSignature; +import org.hyperledger.besu.crypto.SecureRandomProvider; +import org.hyperledger.besu.crypto.SignatureAlgorithm; +import org.hyperledger.besu.crypto.SignatureAlgorithmFactory; +import org.hyperledger.besu.datatypes.Address; +import org.hyperledger.besu.ethereum.core.Util; + +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.List; + +import org.apache.tuweni.bytes.Bytes; +import org.apache.tuweni.bytes.Bytes32; +import org.bouncycastle.crypto.AsymmetricCipherKeyPair; +import org.bouncycastle.pqc.crypto.falcon.FalconKeyGenerationParameters; +import org.bouncycastle.pqc.crypto.falcon.FalconKeyPairGenerator; +import org.bouncycastle.pqc.crypto.falcon.FalconParameters; +import org.bouncycastle.pqc.crypto.falcon.FalconPrivateKeyParameters; +import org.bouncycastle.pqc.crypto.falcon.FalconPublicKeyParameters; +import org.bouncycastle.pqc.crypto.falcon.FalconSigner; + +/** + * AERE D-146 (2026-08-06). The shared probe fleet every arming fixture is now built from, and the + * reason it had to exist. + * + *

WHAT IT REPLACED, and why the replacement is not cosmetic. Until 2026-08-06 seven separate + * fixtures built their registries around addresses spelled {@code String.format("0x%040x", 0xA00 + + * i)}. Those addresses are arithmetic, not keys: no secp256k1 private key produces them, so no + * validator can ever sign a D-146 claim for one. The moment {@code AERE-PQC-REG-ARM-02} was wired + * into {@code FalconSealSupport}, all seven fixtures described a fleet that CANNOT EXIST - armed, + * and provably unable to produce the registry the arming path now requires. Measured on 2026-08-06: + * 35 tests across 7 classes, every failure carrying AERE-PQC-REG-ARM-02. + * + *

So each row here starts from a REAL secp256k1 key pair and the address is DERIVED from it, + * exactly as a validator's address is derived on the live fleet. That single change is what makes a + * v2 row signable at all. + * + *

WHY A SHARED POOL AND NOT A HELPER PER TEST. Falcon-512 key generation dominates the runtime of + * these fixtures, and every one of them wants the same thing: n validators, each with a Falcon pair, + * an ECDSA pair, and proofs over whatever {@code (chainId, bindHeight, count)} that test declares. + * Generating the pool once per JVM turns seven independent keygen loops into one. It also makes the + * fixtures COMPARABLE: two tests asking for index 3 get the same validator, so a registry written by + * one and read by another is the same fleet. + * + *

WHAT IS DELIBERATELY NOT SHARED: the proofs. {@link #popHex} and {@link #claimHex} take + * {@code chainId}, {@code bindHeight} and {@code count} because all three are inside the signed + * pre-image. A cached proof would be a proof for another registry, and the loader would - correctly + * - refuse it. Caching the KEYS is safe; caching the SIGNATURES would defeat the test. + * + *

KEYS. Every key here is generated in memory at test time and never written outside a JUnit + * temporary directory. Nothing in this file reads or produces fleet key material. + */ +public final class PqV2Fixture { + + /** Size of the probe pool. Larger than any fixture needs, so one pool serves all of them. */ + private static final int POOL = 16; + + private static List pool; + private static SignatureAlgorithm ecdsa; + + private PqV2Fixture() {} + + /** One validator's material: a Falcon pair plus the ECDSA consensus key that must vouch for it. */ + private record Holder( + byte[] falconPublicKey, FalconPrivateKeyParameters falconPrivate, KeyPair ecdsa) {} + + private static synchronized List pool() { + if (pool == null) { + ecdsa = SignatureAlgorithmFactory.getInstance(); + final SecureRandom rnd = SecureRandomProvider.publicSecureRandom(); + final FalconKeyPairGenerator gen = new FalconKeyPairGenerator(); + gen.init(new FalconKeyGenerationParameters(rnd, FalconParameters.falcon_512)); + final List built = new ArrayList<>(POOL); + for (int i = 0; i < POOL; i++) { + final AsymmetricCipherKeyPair kp = gen.generateKeyPair(); + built.add( + new Holder( + ((FalconPublicKeyParameters) kp.getPublic()).getH(), + (FalconPrivateKeyParameters) kp.getPrivate(), + ecdsa.generateKeyPair())); + } + pool = built; + } + return pool; + } + + /** + * The validator address of row {@code i}, DERIVED from that row's secp256k1 public key. + * + * @param i the row index + * @return the derived validator address + */ + public static Address address(final int i) { + return Util.publicKeyToAddress(pool().get(i).ecdsa().getPublicKey()); + } + + /** + * The raw Falcon-512 public key of row {@code i}. + * + * @param i the row index + * @return the raw public key bytes + */ + public static byte[] publicKey(final int i) { + return pool().get(i).falconPublicKey(); + } + + /** + * The Falcon private key of row {@code i}, for fixtures that must also SIGN as that validator. + * + * @param i the row index + * @return the Falcon private key parameters + */ + public static FalconPrivateKeyParameters privateKey(final int i) { + return pool().get(i).falconPrivate(); + } + + /** + * The Falcon possession proof of row {@code i}, over the pre-image this registry declares. + * + * @param chainId the chain id the registry declares + * @param bindHeight the activation height the registry declares + * @param count the row count the registry declares + * @param i the row index + * @return the proof, 0x-prefixed hex + */ + public static String popHex( + final long chainId, final long bindHeight, final int count, final int i) { + final Bytes32 digest = + PqRegistryBinding.possessionDigest( + chainId, bindHeight, count, i, address(i).getBytes().toArray(), publicKey(i)); + final FalconSigner signer = new FalconSigner(); + signer.init(true, privateKey(i)); + return Bytes.wrap(signer.generateSignature(digest.toArray())).toHexString(); + } + + /** + * The ECDSA claim of row {@code i}, signed by that row's OWN consensus key. + * + * @param chainId the chain id the registry declares + * @param bindHeight the activation height the registry declares + * @param count the row count the registry declares + * @param i the row index + * @return the claim signature, 0x-prefixed hex + */ + public static String claimHex( + final long chainId, final long bindHeight, final int count, final int i) { + final Bytes32 digest = + PqRegistryBinding.claimDigest( + chainId, bindHeight, count, i, address(i).getBytes().toArray(), publicKey(i)); + final List holders = pool(); // also the point at which 'ecdsa' is initialised + final SECPSignature s = ecdsa.sign(digest, holders.get(i).ecdsa()); + return Bytes.wrap(s.encodedBytes().toArrayUnsafe()).toHexString(); + } + + /** + * The manifest-level header a v2 registry must carry, as JSON object fields with a trailing comma + * omitted: {@code "count":n,"chainId":c,"bindHeight":h,"formatVersion":2}. + * + * @param count the row count + * @param chainId the chain id + * @param bindHeight the activation height + * @return the header fields, ready to sit at the head of a manifest object + */ + public static String manifestHeader(final int count, final long chainId, final long bindHeight) { + return "\"count\":" + + count + + ",\"chainId\":" + + chainId + + ",\"bindHeight\":" + + bindHeight + + ",\"formatVersion\":" + + PqRegistryBinding.FORMAT_VERSION; + } + + /** + * One v2 manifest row, as a JSON object field: {@code "i":{"addr":..,"pk":..,"pop":..,"claim":..}}. + * + * @param i the row index + * @param count the row count the registry declares + * @param chainId the chain id the registry declares + * @param bindHeight the activation height the registry declares + * @return the row, ready to append after a comma + */ + public static String manifestEntry( + final int i, final int count, final long chainId, final long bindHeight) { + return "\"" + + i + + "\":{\"addr\":\"" + + address(i).toHexString() + + "\",\"pk\":\"" + + Bytes.wrap(publicKey(i)).toHexString() + + "\",\"pop\":\"" + + popHex(chainId, bindHeight, count, i) + + "\",\"claim\":\"" + + claimHex(chainId, bindHeight, count, i) + + "\"}"; + } + + /** + * The bytes the genesis anchor slot commits to for row {@code i}: {@code address || publicKey}, + * which is what {@code hashV0Legacy} accumulates. Unchanged by D-146 - the proofs are outside the + * legacy pre-image - and kept here so a fixture cannot drift from the row it just wrote. + * + * @param i the row index + * @return the anchored pre-image bytes for that row + */ + public static byte[] anchorPreimageRow(final int i) { + final byte[] a = address(i).getBytes().toArray(); + final byte[] k = publicKey(i); + final byte[] out = new byte[a.length + k.length]; + System.arraycopy(a, 0, out, 0, a.length); + System.arraycopy(k, 0, out, a.length, k.length); + return out; + } +} diff --git a/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PreparePqAttachGateTest.java b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PreparePqAttachGateTest.java new file mode 100755 index 000000000..516897846 --- /dev/null +++ b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PreparePqAttachGateTest.java @@ -0,0 +1,101 @@ +/* + * Copyright contributors to Besu. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.apache.tuweni.bytes.Bytes32; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +/** + * THE GATE that emits a seal on PREPARE (step 2 of the 2026-08-28 design note). + * + *

What is measured here is the CONFIGURATION SURFACE, which is exactly the part that gets typed + * by hand and therefore mistyped: absent means never, a good value means from that height onwards, + * and a MISTYPED value refuses loudly instead of booting the node disarmed. The lesson paid for in + * the anchor loader is that a stray character must never disarm silently, because then nobody finds + * out. + * + *

What is NOT measured here, and it is said plainly: that an ARMED node actually produces a + * seal. That needs a Falcon key and a registry bound to addresses, which means a network; it is + * measured at the coverage step, on a testnet. What is proven here is that the gate is closed by + * default and cannot be opened by accident. + */ +class PreparePqAttachGateTest { + + @AfterEach + void clearTheProperty() { + System.clearProperty(FalconSealSupport.PREPARE_ATTACH_PROPERTY); + } + + @Test + void withoutThePropertyTheGateIsClosedForever() { + assertThat(FalconSealSupport.prepareAttachBlock()).isEqualTo(Long.MAX_VALUE); + } + + @Test + void aGoodValueIsReadAsGiven() { + System.setProperty(FalconSealSupport.PREPARE_ATTACH_PROPERTY, "16500000"); + assertThat(FalconSealSupport.prepareAttachBlock()).isEqualTo(16_500_000L); + } + + @Test + void zeroIsALEGALValue() { + // A threshold of zero means "from genesis", and that is a legitimate configuration on a + // testnet. Treated as "unset", a correctly configured testnet would run disarmed in silence. + System.setProperty(FalconSealSupport.PREPARE_ATTACH_PROPERTY, "0"); + assertThat(FalconSealSupport.prepareAttachBlock()).isZero(); + } + + @Test + void aMISTYPEDValueRefusesLoudly() { + for (final String bad : new String[] {"nu-e-numar", "16_500_000", "1e6", "-1", " "}) { + System.setProperty(FalconSealSupport.PREPARE_ATTACH_PROPERTY, bad); + if (bad.isBlank()) { + // whitespace is "unset", not a mistyped value: an empty field in a configuration file + // must not stop a node + assertThat(FalconSealSupport.prepareAttachBlock()).isEqualTo(Long.MAX_VALUE); + continue; + } + assertThatThrownBy(FalconSealSupport::prepareAttachBlock) + .as("the value '%s'", bad) + .isInstanceOf(FalconSealSupport.ActivationConfigException.class) + .hasMessageContaining("AERE-PQC-PREPARE-CONF-01"); + } + } + + @Test + void withNoKeyNothingIsSignedEvenWithTheGateOpen() { + // The gate is open from genesis and still nothing comes out: the node has no Falcon key. That + // is precisely the condition that makes the binary safe to roll onto the fleet before any + // decision is taken. + System.setProperty(FalconSealSupport.PREPARE_ATTACH_PROPERTY, "0"); + assertThat(FalconSealSupport.instance().signPrepare(1L, Bytes32.ZERO)).isEmpty(); + } + + @Test + void thePREPAREGateIsNotTheCOMMITGate() { + // If it were the same one, rolling the binary onto the fleet would become a flag day: PREPARE + // emission would start the moment commit emission does, and that one is already on since block + // 13,889,296 on chain 2800. + assertThat(FalconSealSupport.PREPARE_ATTACH_PROPERTY).isNotEqualTo("aere.falcon.attachBlock"); + System.setProperty(FalconSealSupport.PREPARE_ATTACH_PROPERTY, "16500000"); + assertThat(FalconSealSupport.prepareAttachBlock()).isEqualTo(16_500_000L); + // the commit property stays untouched by the PREPARE one + assertThat(System.getProperty("aere.falcon.attachBlock")).isNull(); + } +} diff --git a/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/ProposalPqAttachGateTest.java b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/ProposalPqAttachGateTest.java new file mode 100755 index 000000000..00c8eacf9 --- /dev/null +++ b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/ProposalPqAttachGateTest.java @@ -0,0 +1,104 @@ +/* + * Copyright contributors to Besu. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.apache.tuweni.bytes.Bytes32; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +/** + * THE GATE that emits a seal on the PROPOSAL (AERE PQ, 2026-08-30). Twin of the PREPARE gate, + * with the same shape on purpose. + * + *

What is measured here is the CONFIGURATION SURFACE, which is exactly the part that gets typed + * by hand and therefore mistyped: absent means never, a good value means from that height onwards, + * and a MISTYPED value refuses loudly instead of booting the node disarmed. The lesson paid for in + * the anchor loader is that a stray character must never disarm silently, because then nobody finds + * out. + * + *

What is NOT measured here, and it is said plainly: that an ARMED node actually produces a + * seal. That needs a Falcon key and a registry bound to addresses, which means a network; it is + * measured at the coverage step, on a testnet. What is proven here is that the gate is closed by + * default and cannot be opened by accident. + */ +class ProposalPqAttachGateTest { + + @AfterEach + void clearTheProperty() { + System.clearProperty(FalconSealSupport.PROPOSAL_ATTACH_PROPERTY); + } + + @Test + void withoutThePropertyTheGateIsClosedForever() { + assertThat(FalconSealSupport.proposalAttachBlock()).isEqualTo(Long.MAX_VALUE); + } + + @Test + void aGoodValueIsReadAsGiven() { + System.setProperty(FalconSealSupport.PROPOSAL_ATTACH_PROPERTY, "16500000"); + assertThat(FalconSealSupport.proposalAttachBlock()).isEqualTo(16_500_000L); + } + + @Test + void zeroIsALEGALValue() { + // A threshold of zero means "from genesis", and that is a legitimate configuration on a + // testnet. Treated as "unset", a correctly configured testnet would run disarmed in silence. + System.setProperty(FalconSealSupport.PROPOSAL_ATTACH_PROPERTY, "0"); + assertThat(FalconSealSupport.proposalAttachBlock()).isZero(); + } + + @Test + void aMISTYPEDValueRefusesLoudly() { + for (final String bad : new String[] {"nu-e-numar", "16_500_000", "1e6", "-1", " "}) { + System.setProperty(FalconSealSupport.PROPOSAL_ATTACH_PROPERTY, bad); + if (bad.isBlank()) { + // whitespace is "unset", not a mistyped value: an empty field in a configuration file + // must not stop a node + assertThat(FalconSealSupport.proposalAttachBlock()).isEqualTo(Long.MAX_VALUE); + continue; + } + assertThatThrownBy(FalconSealSupport::proposalAttachBlock) + .as("the value '%s'", bad) + .isInstanceOf(FalconSealSupport.ActivationConfigException.class) + .hasMessageContaining("AERE-PQC-PROPOSAL-CONF-01"); + } + } + + @Test + void withNoKeyNothingIsSignedEvenWithTheGateOpen() { + // The gate is open from genesis and still nothing comes out: the node has no Falcon key. That + // is precisely the condition that makes the binary safe to roll onto the fleet before any + // decision is taken. + System.setProperty(FalconSealSupport.PROPOSAL_ATTACH_PROPERTY, "0"); + assertThat(FalconSealSupport.instance().signProposal(1L, Bytes32.ZERO)).isEmpty(); + } + + @Test + void thePROPOSALGateIsNotThePREPAREOrCOMMITGate() { + // Three layers, three switches. If any two shared one, the day one is turned on would become a + // flag day for the other - and commit emission is already on since block 13,889,296 on 2800. + assertThat(FalconSealSupport.PROPOSAL_ATTACH_PROPERTY).isNotEqualTo("aere.falcon.attachBlock"); + assertThat(FalconSealSupport.PROPOSAL_ATTACH_PROPERTY) + .isNotEqualTo(FalconSealSupport.PREPARE_ATTACH_PROPERTY); + System.setProperty(FalconSealSupport.PROPOSAL_ATTACH_PROPERTY, "16500000"); + assertThat(FalconSealSupport.proposalAttachBlock()).isEqualTo(16_500_000L); + // the commit and PREPARE properties stay untouched by the PROPOSAL one + assertThat(System.getProperty("aere.falcon.attachBlock")).isNull(); + assertThat(System.getProperty(FalconSealSupport.PREPARE_ATTACH_PROPERTY)).isNull(); + } +} diff --git a/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/RoundChangePqAttachGateTest.java b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/RoundChangePqAttachGateTest.java new file mode 100755 index 000000000..19c105568 --- /dev/null +++ b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/RoundChangePqAttachGateTest.java @@ -0,0 +1,108 @@ +/* + * Copyright contributors to Besu. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.apache.tuweni.bytes.Bytes32; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +/** + * THE GATE that emits a seal on the ROUND-CHANGE (AERE PQ, 2026-08-31). Twin of the PREPARE and + * PROPOSAL gates, with the same shape on purpose. + * + *

What is measured here is the CONFIGURATION SURFACE, which is exactly the part that gets typed + * by hand and therefore mistyped: absent means never, a good value means from that height onwards, + * and a MISTYPED value refuses loudly instead of booting the node disarmed. The lesson paid for in + * the anchor loader is that a stray character must never disarm silently, because then nobody finds + * out. + * + *

What is NOT measured here, and it is said plainly: that an ARMED node actually produces a + * seal. That needs a Falcon key, a registry bound to addresses, and a FAILED round to provoke the + * message at all, which means a network; it is measured at the coverage step, on a testnet. What is + * proven here is that the gate is closed by default and cannot be opened by accident. + */ +class RoundChangePqAttachGateTest { + + @AfterEach + void clearTheProperty() { + System.clearProperty(FalconSealSupport.ROUNDCHANGE_ATTACH_PROPERTY); + } + + @Test + void withoutThePropertyTheGateIsClosedForever() { + assertThat(FalconSealSupport.roundChangeAttachBlock()).isEqualTo(Long.MAX_VALUE); + } + + @Test + void aGoodValueIsReadAsGiven() { + System.setProperty(FalconSealSupport.ROUNDCHANGE_ATTACH_PROPERTY, "16500000"); + assertThat(FalconSealSupport.roundChangeAttachBlock()).isEqualTo(16_500_000L); + } + + @Test + void zeroIsALEGALValue() { + // A threshold of zero means "from genesis", and that is a legitimate configuration on a + // testnet. Treated as "unset", a correctly configured testnet would run disarmed in silence. + System.setProperty(FalconSealSupport.ROUNDCHANGE_ATTACH_PROPERTY, "0"); + assertThat(FalconSealSupport.roundChangeAttachBlock()).isZero(); + } + + @Test + void aMISTYPEDValueRefusesLoudly() { + for (final String bad : new String[] {"nu-e-numar", "16_500_000", "1e6", "-1", " "}) { + System.setProperty(FalconSealSupport.ROUNDCHANGE_ATTACH_PROPERTY, bad); + if (bad.isBlank()) { + // whitespace is "unset", not a mistyped value: an empty field in a configuration file + // must not stop a node + assertThat(FalconSealSupport.roundChangeAttachBlock()).isEqualTo(Long.MAX_VALUE); + continue; + } + assertThatThrownBy(FalconSealSupport::roundChangeAttachBlock) + .as("the value '%s'", bad) + .isInstanceOf(FalconSealSupport.ActivationConfigException.class) + .hasMessageContaining("AERE-PQC-ROUNDCHANGE-CONF-01"); + } + } + + @Test + void withNoKeyNothingIsSignedEvenWithTheGateOpen() { + // The gate is open from genesis and still nothing comes out: the node has no Falcon key. That + // is precisely the condition that makes the binary safe to roll onto the fleet before any + // decision is taken. + System.setProperty(FalconSealSupport.ROUNDCHANGE_ATTACH_PROPERTY, "0"); + assertThat(FalconSealSupport.instance().signRoundChange(1L, Bytes32.ZERO)).isEmpty(); + } + + @Test + void theROUNDCHANGEGateIsNoneOfItsThreeSiblings() { + // Four layers, four switches. If any two shared one, the day one is turned on would become a + // flag day for the other - and commit emission is already on since block 13,889,296 on 2800. + assertThat(FalconSealSupport.ROUNDCHANGE_ATTACH_PROPERTY) + .isNotEqualTo("aere.falcon.attachBlock"); + assertThat(FalconSealSupport.ROUNDCHANGE_ATTACH_PROPERTY) + .isNotEqualTo(FalconSealSupport.PREPARE_ATTACH_PROPERTY); + assertThat(FalconSealSupport.ROUNDCHANGE_ATTACH_PROPERTY) + .isNotEqualTo(FalconSealSupport.PROPOSAL_ATTACH_PROPERTY); + System.setProperty(FalconSealSupport.ROUNDCHANGE_ATTACH_PROPERTY, "16500000"); + assertThat(FalconSealSupport.roundChangeAttachBlock()).isEqualTo(16_500_000L); + // the commit, PREPARE and PROPOSAL properties stay untouched by the ROUND-CHANGE one + assertThat(System.getProperty("aere.falcon.attachBlock")).isNull(); + assertThat(System.getProperty(FalconSealSupport.PREPARE_ATTACH_PROPERTY)).isNull(); + assertThat(System.getProperty(FalconSealSupport.PROPOSAL_ATTACH_PROPERTY)).isNull(); + } +} diff --git a/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/SealSchemeAgilityTest.java b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/SealSchemeAgilityTest.java new file mode 100755 index 000000000..91ca9bbfb --- /dev/null +++ b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/SealSchemeAgilityTest.java @@ -0,0 +1,145 @@ +/* AERE crypto-agility, step 1 proofs. Every green here has a red twin: flipped signatures, + * flipped messages, wrong keys, and the cross-scheme controls that are the whole point of the + * layer (a Falcon artefact must never verify as SLH-DSA, and vice versa). A layer whose schemes + * cannot be told apart would be worse than no layer. */ +package org.hyperledger.besu.consensus.common.bft; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.Optional; + +import org.hyperledger.besu.crypto.SecureRandomProvider; +import org.junit.jupiter.api.Test; + +class SealSchemeAgilityTest { + + private static final byte[] MESSAGE = "aere anchor commit hash stand-in".getBytes(StandardCharsets.UTF_8); + private static final byte[] OTHER_MESSAGE = "a different message entirely....".getBytes(StandardCharsets.UTF_8); + + private final SecureRandom random = SecureRandomProvider.createSecureRandom(); + + // ------------------------------------------------------------------ per-scheme sign/verify + + @Test + void falconSignsAndVerifies() { + roundTrip(SealSchemes.FALCON_512); + } + + @Test + void slhDsaSignsAndVerifies() { + roundTrip(SealSchemes.SLH_DSA_128S); + } + + private void roundTrip(final SealScheme scheme) { + final SealScheme.GeneratedPair pair = scheme.generate(random); + final Optional sig = scheme.sign(pair.privateKey(), MESSAGE); + assertThat(sig).isPresent(); + assertThat(scheme.verify(pair.publicKey(), MESSAGE, sig.get())).isTrue(); + assertThat(scheme.verifyRaw(pair.publicRegistryForm(), MESSAGE, sig.get())).isTrue(); + } + + // ------------------------------------------------------------------ negative controls + + @Test + void flippedSignatureBitIsRejectedByBothSchemes() { + for (final SealScheme scheme : SealSchemes.all()) { + final SealScheme.GeneratedPair pair = scheme.generate(random); + final byte[] sig = scheme.sign(pair.privateKey(), MESSAGE).orElseThrow(); + sig[sig.length / 2] ^= 0x01; + assertThat(scheme.verify(pair.publicKey(), MESSAGE, sig)) + .as("%s must reject a signature with one flipped bit", scheme.id()) + .isFalse(); + } + } + + @Test + void flippedMessageIsRejectedByBothSchemes() { + for (final SealScheme scheme : SealSchemes.all()) { + final SealScheme.GeneratedPair pair = scheme.generate(random); + final byte[] sig = scheme.sign(pair.privateKey(), MESSAGE).orElseThrow(); + assertThat(scheme.verify(pair.publicKey(), OTHER_MESSAGE, sig)) + .as("%s must reject the signature over a different message", scheme.id()) + .isFalse(); + } + } + + @Test + void wrongKeyIsRejectedByBothSchemes() { + for (final SealScheme scheme : SealSchemes.all()) { + final SealScheme.GeneratedPair signer = scheme.generate(random); + final SealScheme.GeneratedPair stranger = scheme.generate(random); + final byte[] sig = scheme.sign(signer.privateKey(), MESSAGE).orElseThrow(); + assertThat(scheme.verify(stranger.publicKey(), MESSAGE, sig)) + .as("%s must reject a signature under a stranger's key", scheme.id()) + .isFalse(); + } + } + + // ------------------------------------------------------- the point of the layer: cross-scheme + + @Test + void falconArtefactsNeverVerifyAsSlhDsa() { + final SealScheme.GeneratedPair falcon = SealSchemes.FALCON_512.generate(random); + final byte[] falconSig = SealSchemes.FALCON_512.sign(falcon.privateKey(), MESSAGE).orElseThrow(); + // the raw Falcon key is not even parseable as an SLH-DSA key (896 vs 32 bytes)... + assertThat(SealSchemes.SLH_DSA_128S.parsePublicKey(falcon.publicRegistryForm())).isEmpty(); + // ...and the raw path must answer false, never throw + assertThat(SealSchemes.SLH_DSA_128S.verifyRaw(falcon.publicRegistryForm(), MESSAGE, falconSig)).isFalse(); + // a Falcon PRIVATE handle fed to the SLH-DSA signer must refuse, not sign garbage + assertThat(SealSchemes.SLH_DSA_128S.sign(falcon.privateKey(), MESSAGE)).isEmpty(); + } + + @Test + void slhDsaArtefactsNeverVerifyAsFalcon() { + final SealScheme.GeneratedPair slh = SealSchemes.SLH_DSA_128S.generate(random); + final byte[] slhSig = SealSchemes.SLH_DSA_128S.sign(slh.privateKey(), MESSAGE).orElseThrow(); + assertThat(SealSchemes.FALCON_512.parsePublicKey(slh.publicRegistryForm())).isEmpty(); + assertThat(SealSchemes.FALCON_512.verifyRaw(slh.publicRegistryForm(), MESSAGE, slhSig)).isFalse(); + assertThat(SealSchemes.FALCON_512.sign(slh.privateKey(), MESSAGE)).isEmpty(); + } + + @Test + void crossSchemeHandlesAreRejectedOnVerifyToo() { + final SealScheme.GeneratedPair falcon = SealSchemes.FALCON_512.generate(random); + final SealScheme.GeneratedPair slh = SealSchemes.SLH_DSA_128S.generate(random); + final byte[] falconSig = SealSchemes.FALCON_512.sign(falcon.privateKey(), MESSAGE).orElseThrow(); + // a foreign PUBLIC handle on verify: false, never a ClassCastException + assertThat(SealSchemes.SLH_DSA_128S.verify(falcon.publicKey(), MESSAGE, falconSig)).isFalse(); + assertThat(SealSchemes.FALCON_512.verify(slh.publicKey(), MESSAGE, falconSig)).isFalse(); + } + + // ------------------------------------------------------------------ registry and wire form + + @Test + void registryFindsSchemesByIdAndWireTag() { + assertThat(SealSchemes.byId("falcon-512")).contains(SealSchemes.FALCON_512); + assertThat(SealSchemes.byId("slh-dsa-128s")).contains(SealSchemes.SLH_DSA_128S); + assertThat(SealSchemes.byWireId((byte) 0x01)).contains(SealSchemes.FALCON_512); + assertThat(SealSchemes.byWireId((byte) 0x02)).contains(SealSchemes.SLH_DSA_128S); + } + + @Test + void unknownSchemesAreLoudlyAbsentNeverDefaulted() { + assertThat(SealSchemes.byId("dilithium-notyet")).isEmpty(); + assertThat(SealSchemes.byId(null)).isEmpty(); + // 0x00 is the legacy untagged certificate, deliberately NOT resolvable as a scheme + assertThat(SealSchemes.byWireId((byte) 0x00)).isEmpty(); + assertThat(SealSchemes.byWireId((byte) 0x7f)).isEmpty(); + } + + @Test + void registryFormsHaveTheDocumentedLengths() { + // Falcon-512: 896 raw h bytes, the exact form the signer registry stores (measured on the + // proof-network registry files). The 897-byte pk(897) = 0x09 || h is the PRECOMPILE input + // format, one layer above; the first form of this assertion said 897 and went red, which is + // the measurement this comment records. Locking 896 here means a scheme change cannot + // silently change what a registry entry means. + assertThat(SealSchemes.FALCON_512.publicKeyLength()).isEqualTo(896); + assertThat(SealSchemes.FALCON_512.generate(random).publicRegistryForm()).hasSize(896); + // SLH-DSA-128s: 32 bytes (PK.seed || PK.root) per FIPS 205. + assertThat(SealSchemes.SLH_DSA_128S.publicKeyLength()).isEqualTo(32); + assertThat(SealSchemes.SLH_DSA_128S.generate(random).publicRegistryForm()).hasSize(32); + } +} diff --git a/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/SlhDsaCrossVectorTest.java b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/SlhDsaCrossVectorTest.java new file mode 100755 index 000000000..00891a0fe --- /dev/null +++ b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/SlhDsaCrossVectorTest.java @@ -0,0 +1,82 @@ +/* + * Copyright contributors to Aere Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in + * compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is + * distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the License for the specific language governing permissions and limitations under the + * License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.apache.tuweni.bytes.Bytes; +import org.junit.jupiter.api.Test; + +/** + * D-325 (2026-09-03), the CROSS-IMPLEMENTATION vector: an SLH-DSA-SHA2-128s signature produced by the public + * verifier's library (@noble/post-quantum 0.7.1, FIPS 205 external form, empty context) must verify under this + * fork's {@link SlhDsaSealScheme} (BouncyCastle Java). Two implementations agreeing on one vector is the only + * proof that "the same form" is the same form; two self-consistent implementations prove nothing about each other. + * + *

With its pair: the same signature over a message that differs in one bit does NOT verify, and a signature with + * one bit flipped does NOT verify. A verifier that accepted everything would pass the positive half alone. + */ +class SlhDsaCrossVectorTest { + + private static String field(final String json, final String name) { + final Matcher m = Pattern.compile("\"" + name + "\"\\s*:\\s*\"([0-9a-fA-F]+)\"").matcher(json); + if (!m.find()) { + throw new IllegalStateException("vector has no field " + name); + } + return m.group(1); + } + + private static String vector() throws IOException { + try (InputStream in = + SlhDsaCrossVectorTest.class.getResourceAsStream("/vector-slh-dsa-noble-2026-09-03.json")) { + if (in == null) { + throw new IllegalStateException("vector-slh-dsa-noble-2026-09-03.json is not on the test classpath"); + } + return new String(in.readAllBytes(), StandardCharsets.UTF_8); + } + } + + @Test + void aSignatureFromTheJavaScriptImplementationVerifiesHereAndItsTamperedTwinsDoNot() throws IOException { + final String v = vector(); + final byte[] pk = Bytes.fromHexString(field(v, "pk")).toArray(); + final byte[] msg = Bytes.fromHexString(field(v, "msg")).toArray(); + final byte[] sig = Bytes.fromHexString(field(v, "sig")).toArray(); + assertThat(pk).hasSize(32); + assertThat(msg).hasSize(32); + assertThat(sig).hasSize(7856); + final SealScheme scheme = SealSchemes.SLH_DSA_128S; + assertThat(scheme.verifyRaw(pk, msg, sig)) + .describedAs("the @noble/post-quantum signature verifies under BouncyCastle's SLH-DSA-SHA2-128s (external form)") + .isTrue(); + final byte[] otherMsg = msg.clone(); + otherMsg[0] ^= 0x01; + assertThat(scheme.verifyRaw(pk, otherMsg, sig)).describedAs("one message bit flipped").isFalse(); + final byte[] badSig = sig.clone(); + badSig[badSig.length / 2] ^= 0x01; + assertThat(scheme.verifyRaw(pk, msg, badSig)).describedAs("one signature bit flipped").isFalse(); + // and the INTERNAL form (what the precompile at 0x0AE4 uses) must NOT be confused with it: a scheme that + // accepted both would let a seal stand in for a precompile call or the other way round. + assertThat(SealSchemes.byId("slh-dsa-sha2-128s")).isPresent(); + assertThat(SealSchemes.byId("slh-dsa-128s")).describedAs("the pre-D-325 alias still resolves").isPresent(); + } +} diff --git a/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/SlhDsaFastEngineTest.java b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/SlhDsaFastEngineTest.java new file mode 100755 index 000000000..d1ca77a67 --- /dev/null +++ b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/SlhDsaFastEngineTest.java @@ -0,0 +1,141 @@ +/* + * Copyright contributors to Aere Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in + * compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is + * distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the License for the specific language governing permissions and limitations under the + * License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.Arrays; +import org.bouncycastle.crypto.AsymmetricCipherKeyPair; +import org.hyperledger.besu.consensus.common.bft.slhdsa.SLHDSAKeyGenerationParameters; +import org.hyperledger.besu.consensus.common.bft.slhdsa.SLHDSAKeyPairGenerator; +import org.hyperledger.besu.consensus.common.bft.slhdsa.SLHDSAParameters; +import org.hyperledger.besu.consensus.common.bft.slhdsa.SLHDSAPrivateKeyParameters; +import org.hyperledger.besu.consensus.common.bft.slhdsa.SLHDSAPublicKeyParameters; +import org.hyperledger.besu.consensus.common.bft.slhdsa.SLHDSASigner; +import org.hyperledger.besu.crypto.SecureRandomProvider; +import org.junit.jupiter.api.Test; + +/** + * D-337 (2026-09-04): the in-tree SLH-DSA engine (Bouncy Castle's algorithm on the JDK's SHA-NI + * digest) must be the SAME signature scheme as the Bouncy Castle original, bit for bit. Otherwise a + * faster signer is a different certificate, and the second client and the public verifier would + * reject every anchor. Each direction is pinned: fast-signed verifies under the original, original- + * signed verifies under the fast engine, deterministic signing gives identical bytes, and the fast + * engine still rejects a tampered signature (the negative control of the verifier). + */ +class SlhDsaFastEngineTest { + + private static final org.bouncycastle.pqc.crypto.slhdsa.SLHDSAParameters ORIGINAL = + org.bouncycastle.pqc.crypto.slhdsa.SLHDSAParameters.sha2_128s; + + private static AsymmetricCipherKeyPair fastKeyPair() { + final SLHDSAKeyPairGenerator gen = new SLHDSAKeyPairGenerator(); + gen.init(new SLHDSAKeyGenerationParameters(SecureRandomProvider.createSecureRandom(), SLHDSAParameters.sha2_128s)); + return gen.generateKeyPair(); + } + + private static org.bouncycastle.pqc.crypto.slhdsa.SLHDSAPublicKeyParameters originalPub( + final AsymmetricCipherKeyPair kp) { + return new org.bouncycastle.pqc.crypto.slhdsa.SLHDSAPublicKeyParameters( + ORIGINAL, ((SLHDSAPublicKeyParameters) kp.getPublic()).getEncoded()); + } + + private static org.bouncycastle.pqc.crypto.slhdsa.SLHDSAPrivateKeyParameters originalPriv( + final AsymmetricCipherKeyPair kp) { + return new org.bouncycastle.pqc.crypto.slhdsa.SLHDSAPrivateKeyParameters( + ORIGINAL, ((SLHDSAPrivateKeyParameters) kp.getPrivate()).getEncoded()); + } + + @Test + void aFastSignatureVerifiesUnderTheOriginalBouncyCastleEngine() { + final AsymmetricCipherKeyPair kp = fastKeyPair(); + final byte[] msg = "AERE-PQ-ANCHOR-2 commit seal message".getBytes(StandardCharsets.UTF_8); + final SLHDSASigner signer = new SLHDSASigner(); + signer.init(true, kp.getPrivate()); + final byte[] sig = signer.generateSignature(msg); + assertThat(sig).hasSize(7856); + + final org.bouncycastle.pqc.crypto.slhdsa.SLHDSASigner original = + new org.bouncycastle.pqc.crypto.slhdsa.SLHDSASigner(); + original.init(false, originalPub(kp)); + assertThat(original.verifySignature(msg, sig)).isTrue(); + + // negative control of the ORIGINAL verifier on the fast signature: one bit off, rejected + final byte[] bad = sig.clone(); + bad[1234] ^= 0x01; + original.init(false, originalPub(kp)); + assertThat(original.verifySignature(msg, bad)).isFalse(); + } + + @Test + void anOriginalSignatureVerifiesUnderTheFastEngineAndATamperedOneDoesNot() { + final AsymmetricCipherKeyPair kp = fastKeyPair(); + final byte[] msg = new byte[32]; + msg[31] = 7; + final org.bouncycastle.pqc.crypto.slhdsa.SLHDSASigner original = + new org.bouncycastle.pqc.crypto.slhdsa.SLHDSASigner(); + original.init(true, originalPriv(kp)); + final byte[] sig = original.generateSignature(msg); + + final SLHDSASigner fast = new SLHDSASigner(); + fast.init(false, kp.getPublic()); + assertThat(fast.verifySignature(msg, sig)).isTrue(); + + final byte[] bad = sig.clone(); + bad[100] ^= 0x01; + fast.init(false, kp.getPublic()); + assertThat(fast.verifySignature(msg, bad)).isFalse(); + + final byte[] otherMsg = msg.clone(); + otherMsg[0] ^= 0x01; + fast.init(false, kp.getPublic()); + assertThat(fast.verifySignature(otherMsg, sig)).isFalse(); + } + + @Test + void deterministicSigningGivesTheSameBytesAsTheOriginal() { + // Without a SecureRandom both engines use the pk seed as opt_rand (FIPS 205 deterministic + // variant), so the SAME key and message must give the SAME 7856 bytes. This is the strongest + // statement that the hash swap changed nothing but the clock. + final AsymmetricCipherKeyPair kp = fastKeyPair(); + final byte[] msg = "deterministic".getBytes(StandardCharsets.UTF_8); + final SLHDSASigner fast = new SLHDSASigner(); + fast.init(true, kp.getPrivate()); + final org.bouncycastle.pqc.crypto.slhdsa.SLHDSASigner original = + new org.bouncycastle.pqc.crypto.slhdsa.SLHDSASigner(); + original.init(true, originalPriv(kp)); + assertThat(Arrays.equals(fast.generateSignature(msg), original.generateSignature(msg))) + .isTrue(); + } + + @Test + void theSealSchemeItselfSignsThroughTheFastEngineAndTheOriginalAccepts() { + final SlhDsaSealScheme scheme = new SlhDsaSealScheme(); + final SealScheme.GeneratedPair pair = scheme.generate(SecureRandomProvider.createSecureRandom()); + final byte[] msg = new byte[32]; + final byte[] sig = scheme.sign(pair.privateKey(), msg).orElseThrow(); + final org.bouncycastle.pqc.crypto.slhdsa.SLHDSASigner original = + new org.bouncycastle.pqc.crypto.slhdsa.SLHDSASigner(); + original.init( + false, + new org.bouncycastle.pqc.crypto.slhdsa.SLHDSAPublicKeyParameters( + ORIGINAL, pair.publicRegistryForm())); + assertThat(original.verifySignature(msg, sig)).isTrue(); + assertThat(scheme.verifyRaw(pair.publicRegistryForm(), msg, sig)).isTrue(); + } +} diff --git a/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/messagewrappers/Commit.java b/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/messagewrappers/Commit.java index 142069ec7..00d7b78c2 100644 --- a/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/messagewrappers/Commit.java +++ b/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/messagewrappers/Commit.java @@ -11,9 +11,16 @@ * specific language governing permissions and limitations under the License. * * SPDX-License-Identifier: Apache-2.0 + * + * Modifications Copyright contributors to the Aere Network. + * + * This file was modified by contributors to the Aere Network, as required by section 4(b) of the + * Apache License 2.0. The copyright header above is the upstream one and is left exactly as it was + * found, as section 4(c) requires. The change itself is in patches/0003-aere-pq-anchor.patch. */ package org.hyperledger.besu.consensus.qbft.core.messagewrappers; +import org.hyperledger.besu.consensus.common.bft.FalconSeal; import org.hyperledger.besu.consensus.common.bft.messagewrappers.BftMessage; import org.hyperledger.besu.consensus.common.bft.payload.SignedData; import org.hyperledger.besu.consensus.qbft.core.payload.CommitPayload; @@ -22,6 +29,8 @@ import org.hyperledger.besu.datatypes.Hash; import org.hyperledger.besu.ethereum.rlp.RLP; import org.hyperledger.besu.ethereum.rlp.RLPInput; +import java.util.Optional; + import org.apache.tuweni.bytes.Bytes; /** The Commit payload message. */ @@ -45,11 +54,34 @@ public class Commit extends BftMessage { return getPayload().getCommitSeal(); } + /** + * Gets the optional parallel post-quantum Falcon seal carried on this commit. + * + * @return the Falcon seal if present, otherwise empty + */ + public Optional getFalconSeal() { + return getPayload().getFalconSeal(); + } + /** * Gets digest. * * @return the digest */ + /** + * D-339 (2026-09-04): the hybrid extra seals this Commit carries, as a plain accessor. + * + *

It exists so the two places that keep seals out of a dying message (the late-seal salvage in + * the controller and the no-proposal path in the round) do not have to reach through the signed + * payload. Reaching through it also broke every test that mocks a Commit, which is exactly the + * kind of surprise a wrapper class is meant to absorb. + * + * @return the non-Falcon scheme seals, empty on every commit that carries none + */ + public java.util.List getExtraSeals() { + return getPayload().getExtraSeals(); + } + public Hash getDigest() { return getPayload().getDigest(); } diff --git a/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/network/QbftMessageTransmitter.java b/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/network/QbftMessageTransmitter.java index 30156ed6d..18437e30f 100644 --- a/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/network/QbftMessageTransmitter.java +++ b/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/network/QbftMessageTransmitter.java @@ -11,10 +11,18 @@ * specific language governing permissions and limitations under the License. * * SPDX-License-Identifier: Apache-2.0 + * + * Modifications Copyright contributors to the Aere Network. + * + * This file was modified by contributors to the Aere Network, as required by section 4(b) of the + * Apache License 2.0. The copyright header above is the upstream one and is left exactly as it was + * found, as section 4(c) requires. The change itself is in patches/0003-aere-pq-anchor.patch. */ package org.hyperledger.besu.consensus.qbft.core.network; import org.hyperledger.besu.consensus.common.bft.ConsensusRoundIdentifier; +import org.hyperledger.besu.consensus.common.bft.FalconSeal; +import org.hyperledger.besu.consensus.common.bft.SchemeSeal; import org.hyperledger.besu.consensus.common.bft.network.ValidatorMulticaster; import org.hyperledger.besu.consensus.common.bft.payload.SignedData; import org.hyperledger.besu.consensus.qbft.core.messagedata.CommitMessageData; @@ -76,10 +84,42 @@ public class QbftMessageTransmitter { final Optional blockAccessList, final List> roundChanges, final List> prepares) { + multicastProposal(roundIdentifier, block, blockAccessList, roundChanges, prepares, + Optional.empty()); + } + + /** + * Multicast proposal carrying the proposer's optional post-quantum seal (AERE PQ, 2026-08-30). + * + *

THE SEAL MUST BE THE SAME OBJECT the caller embedded in its local copy, and this overload + * exists because the first network run proved what happens without it: this method RE-CREATES + * the proposal from scratch, so the local copy carried a seal, the wire copy carried none, and + * at the enforcement height every peer refused round after round while the proposer's own log + * said it had emitted. F84 scenario A, 2026-08-30 - the witness went red, exactly its job. + * The same two-copies trap was closed at PREPARE the day before it could bite; here it bit + * first, on a testnet, which is where it is allowed to. + * + * @param roundIdentifier the round identifier + * @param block the block + * @param blockAccessList the block access list + * @param roundChanges the round changes + * @param prepares the prepares + * @param falconSeal the proposer's post-quantum seal, or empty + */ + public void multicastProposal( + final ConsensusRoundIdentifier roundIdentifier, + final QbftBlock block, + final Optional blockAccessList, + final List> roundChanges, + final List> prepares, + final Optional falconSeal) { try { final Proposal data = - messageFactory.createProposal( - roundIdentifier, block, blockAccessList, roundChanges, prepares); + falconSeal.isPresent() + ? messageFactory.createProposal( + roundIdentifier, block, blockAccessList, roundChanges, prepares, falconSeal) + : messageFactory.createProposal( + roundIdentifier, block, blockAccessList, roundChanges, prepares); final ProposalMessageData message = ProposalMessageData.create(data); @@ -96,8 +136,27 @@ public class QbftMessageTransmitter { * @param digest the digest */ public void multicastPrepare(final ConsensusRoundIdentifier roundIdentifier, final Hash digest) { + multicastPrepare(roundIdentifier, digest, Optional.empty()); + } + + /** + * Multicast a prepare carrying an OPTIONAL post-quantum seal of this node. + * + *

Sigiliul vine GATA CALCULAT de la apelant, si asta nu e comoditate: semnaturile Falcon sunt + * randomized, so signing the same message twice yields two different byte strings. If the local + * copy and the one on the wire each signed their own, the same validator would produce two + * valide si DIFERITE pentru aceeasi runda. Se calculeaza o data, sus, si se trece prin amandoua. + * + * @param roundIdentifier the round identifier + * @param digest the digest + * @param falconSeal the seal, or empty + */ + public void multicastPrepare( + final ConsensusRoundIdentifier roundIdentifier, + final Hash digest, + final Optional falconSeal) { try { - final Prepare data = messageFactory.createPrepare(roundIdentifier, digest); + final Prepare data = messageFactory.createPrepare(roundIdentifier, digest, falconSeal); final PrepareMessageData message = PrepareMessageData.create(data); @@ -118,8 +177,44 @@ public class QbftMessageTransmitter { final ConsensusRoundIdentifier roundIdentifier, final Hash digest, final SECPSignature commitSeal) { + multicastCommit(roundIdentifier, digest, commitSeal, Optional.empty()); + } + + /** + * Multicast commit carrying an optional parallel Falcon-512 seal (AERE hybrid PQC). + * + * @param roundIdentifier the round identifier + * @param digest the digest + * @param commitSeal the ECDSA commit seal + * @param falconSeal the optional parallel post-quantum Falcon seal + */ + public void multicastCommit( + final ConsensusRoundIdentifier roundIdentifier, + final Hash digest, + final SECPSignature commitSeal, + final Optional falconSeal) { + multicastCommit(roundIdentifier, digest, commitSeal, falconSeal, java.util.List.of()); + } + + /** + * Multicast commit carrying a HYBRID post-quantum certificate: the Falcon seal in its own slot + * plus the other schemes' seals alongside it (AERE HIBRID, 2026-08-25). + * + * @param roundIdentifier the round identifier + * @param digest the digest + * @param commitSeal the ECDSA commit seal + * @param falconSeal the optional parallel Falcon seal + * @param extraSeals the non-Falcon scheme seals; empty on every node not hybrid-configured + */ + public void multicastCommit( + final ConsensusRoundIdentifier roundIdentifier, + final Hash digest, + final SECPSignature commitSeal, + final Optional falconSeal, + final java.util.List extraSeals) { try { - final Commit data = messageFactory.createCommit(roundIdentifier, digest, commitSeal); + final Commit data = + messageFactory.createCommit(roundIdentifier, digest, commitSeal, falconSeal, extraSeals); final CommitMessageData message = CommitMessageData.create(data); @@ -149,4 +244,18 @@ public class QbftMessageTransmitter { LOG.warn("Failed to generate signature for RoundChange (not sent): {} ", e.getMessage()); } } + + /** + * Multicast an ALREADY BUILT round change, without re-creating it. AERE PQ (2026-08-31): the + * height manager signs its round-change ONCE - Falcon signatures are randomised, so re-creating + * the message here would put a DIFFERENT object on the wire than the one handled locally, the + * exact defect F84 measured on the PROPOSAL (local copy sealed, wire copy not). The seal-less + * path keeps using the overload above, call for call as upstream. + * + * @param roundChange the round change to send, exactly as built + */ + public void multicastRoundChange(final RoundChange roundChange) { + final RoundChangeMessageData message = RoundChangeMessageData.create(roundChange); + multicaster.send(message); + } } diff --git a/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/CommitPayload.java b/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/CommitPayload.java index db481acd0..8b788f499 100644 --- a/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/CommitPayload.java +++ b/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/CommitPayload.java @@ -11,30 +11,62 @@ * specific language governing permissions and limitations under the License. * * SPDX-License-Identifier: Apache-2.0 + * + * Modifications Copyright contributors to the Aere Network. + * + * This file was modified by contributors to the Aere Network, as required by section 4(b) of the + * Apache License 2.0. The copyright header above is the upstream one and is left exactly as it was + * found, as section 4(c) requires. The change itself is in patches/0003-aere-pq-anchor.patch. */ package org.hyperledger.besu.consensus.qbft.core.payload; import org.hyperledger.besu.consensus.common.bft.ConsensusRoundIdentifier; +import org.hyperledger.besu.consensus.common.bft.FalconSeal; +import org.hyperledger.besu.consensus.common.bft.PqAnchorV2; +import org.hyperledger.besu.consensus.common.bft.SchemeSeal; +import org.hyperledger.besu.consensus.common.bft.SealSchemes; import org.hyperledger.besu.consensus.common.bft.payload.Payload; import org.hyperledger.besu.consensus.qbft.core.messagedata.QbftV1; import org.hyperledger.besu.crypto.SECPSignature; import org.hyperledger.besu.crypto.SignatureAlgorithmFactory; import org.hyperledger.besu.datatypes.Hash; +import org.hyperledger.besu.ethereum.rlp.RLPException; import org.hyperledger.besu.ethereum.rlp.RLPInput; import org.hyperledger.besu.ethereum.rlp.RLPOutput; +import java.util.List; import java.util.Objects; +import java.util.Optional; import java.util.StringJoiner; -/** The Commit payload. */ +import org.apache.tuweni.bytes.Bytes; + +/** + * The Commit payload. + * + *

AERE hybrid PQC extension: a Commit payload MAY carry an OPTIONAL parallel Falcon-512 seal, so + * that every validator's post-quantum seal over the commit hash is GOSSIPED alongside the decisive + * ECDSA commit seal on the same message. The Falcon seal is appended only when present, so a + * Falcon-less Commit encodes byte-for-byte identically to upstream Besu. When present, the whole + * payload (including the Falcon seal) is covered by the author's ECDSA signature, binding the + * claimed Falcon validator index to the sender's identity. + * + *

AERE FIX-MALEABILITATE: this payload has exactly ONE valid encoding for a given value. See + * {@link #readFrom(RLPInput)}. + */ public class CommitPayload extends QbftPayload { private static final int TYPE = QbftV1.COMMIT; private final ConsensusRoundIdentifier roundIdentifier; private final Hash digest; private final SECPSignature commitSeal; + private final Optional falconSeal; + // AERE HIBRID (2026-08-25): the NON-Falcon scheme seals of a hybrid certificate. Falcon keeps + // living in the legacy slot above, so one signature has exactly one home and the wire format of + // a Falcon-only commit is untouched. Empty on every commit the live fleet emits today. + private final List extraSeals; /** - * Instantiates a new Commit payload. + * Instantiates a new Commit payload (no Falcon seal). * * @param roundIdentifier the round identifier * @param digest the digest @@ -44,26 +76,197 @@ public class CommitPayload extends QbftPayload { final ConsensusRoundIdentifier roundIdentifier, final Hash digest, final SECPSignature commitSeal) { + this(roundIdentifier, digest, commitSeal, Optional.empty()); + } + + /** + * Instantiates a new Commit payload with an optional parallel Falcon-512 seal. + * + * @param roundIdentifier the round identifier + * @param digest the digest + * @param commitSeal the ECDSA commit seal (decisive) + * @param falconSeal the optional parallel post-quantum Falcon seal over the same commit hash + */ + public CommitPayload( + final ConsensusRoundIdentifier roundIdentifier, + final Hash digest, + final SECPSignature commitSeal, + final Optional falconSeal) { + this(roundIdentifier, digest, commitSeal, falconSeal, List.of()); + } + + /** + * Instantiates a new Commit payload carrying a HYBRID post-quantum certificate. + * + *

AERE HIBRID (2026-08-25), the founder's step two of 2026-08-07. A hybrid certificate is + * Falcon-512 PLUS a second, structurally unrelated scheme (SLH-DSA/SPHINCS+): if lattices fall + * the hash-based one holds, and the reverse. Falcon stays in the legacy slot and the OTHER + * schemes travel here, so: + * + *

    + *
  • a Falcon-only commit encodes byte-for-byte as it does on the live fleet today, which is + * the condition for warming this binary without a coordinated flag day; + *
  • the two positions are unambiguous by COUNT (0 trailing elements = no PQ, 1 = Falcon + * only, 2 = Falcon + extras), so no clever structural sniffing is needed in a consensus + * decoder, where cleverness is how D-235-class mistakes are made; + *
  • a signature has exactly ONE home, so the two slots can never disagree about Falcon. + *
+ * + *

The extras are encoded with {@link PqAnchorV2}, the same scheme-tagged codec the V2 anchor + * certificate uses: one vocabulary, one canonicality discipline, one place to get it wrong. + * + *

ADDING THIS ELEMENT IS A CONSENSUS BREAKING CHANGE, exactly as {@link #readFrom(RLPInput)} + * warns: an older binary cannot parse a commit that carries it. What protects the fleet is not + * leniency, which cannot work, but the EMISSION gate: nothing emits extras until every peer can + * read them. Same discipline as the Falcon attachment gate. + * + * @param roundIdentifier the round identifier + * @param digest the digest + * @param commitSeal the ECDSA commit seal (decisive) + * @param falconSeal the Falcon seal; REQUIRED whenever extras are present + * @param extraSeals the non-Falcon scheme seals; empty for every commit on the fleet today + */ + public CommitPayload( + final ConsensusRoundIdentifier roundIdentifier, + final Hash digest, + final SECPSignature commitSeal, + final Optional falconSeal, + final List extraSeals) { this.roundIdentifier = roundIdentifier; this.digest = digest; this.commitSeal = commitSeal; + this.falconSeal = falconSeal == null ? Optional.empty() : falconSeal; + this.extraSeals = extraSeals == null ? List.of() : List.copyOf(extraSeals); + if (!this.extraSeals.isEmpty()) { + // The wire format cannot even REPRESENT extras without a Falcon seal, because the slots are + // told apart by count. Refusing here means an object that could not be written correctly + // cannot be built at all, instead of failing later at encode time on the consensus path. + if (this.falconSeal.isEmpty()) { + throw new IllegalArgumentException( + "AERE HIBRID: extra scheme seals require the Falcon seal to be present"); + } + for (final SchemeSeal seal : this.extraSeals) { + if (seal.getSchemeWireId() == SealSchemes.FALCON_512.wireId()) { + throw new IllegalArgumentException( + "AERE HIBRID: Falcon belongs in its own slot, not in the extras"); + } + } + // Validates canonicality and the seal cap NOW, so a payload that cannot be encoded cannot + // exist. PqAnchorV2.encode throws on a non-canonical or oversized certificate. + final Bytes unused = PqAnchorV2.encode(this.extraSeals); + if (unused.isEmpty()) { + throw new IllegalArgumentException("AERE HIBRID: empty encoding of a non-empty certificate"); + } + } } /** * Read from rlp input and return commit payload. * + *

AERE FIX-MALEABILITATE: this decoder is STRICTLY CANONICAL. The payload is taken as an + * isolated RLP item, decoded, re-encoded, and the re-encoding must reproduce the received bytes + * exactly. Anything else is rejected. + * + *

Why canonical and not forward-tolerant. A Commit is an AUTHENTICATED consensus message: + * {@code SignedData.create} recovers the author from {@link QbftPayload#hashForSignature()}, + * which is computed from the RE-ENCODED payload, not from the bytes that arrived. Any input the + * decoder silently ignores therefore does not change the recovered author, so one genuine Commit + * can be rewritten into several distinct byte strings that all still authenticate to the original + * validator. QBFT itself is not fooled, because it deduplicates Commits by AUTHOR, so no vote can + * be forged, stolen or double counted. The gossip layer is, because it deduplicates by message + * CONTENT. Tolerating ignorable input is therefore purely a message amplification channel into + * consensus, and it buys nothing in return, for the reason in the next paragraph. + * + *

What forward tolerance does NOT buy, stated correctly. An earlier version of this method + * ended both lists with {@code leaveListLenient()} and claimed that this generation of nodes + * "can never be made to discard a Commit by a newer peer". That claim was FALSE. Consider a + * future binary that appends a MEANINGFUL element to this payload: it signs over ITS OWN + * encoding, which includes that element. This binary would skip the element, re-encode WITHOUT + * it, obtain a different {@code hashForSignature}, ecrecover a different address, and drop the + * Commit anyway, as coming from a non-validator. Leniency only moves the discard from RLP + * decoding to author validation; it does not prevent it. The only thing that actually lets an old + * binary keep accepting a new peer's Commits is for the new peer NOT to change the signed bytes, + * which is exactly what the attachment gate in {@code FalconSealSupport} enforces. + * + *

Consequence for future format changes: adding any element to this payload is a CONSENSUS + * BREAKING change and must be rolled out behind a height gate, never by relying on old nodes to + * ignore it. + * * @param rlpInput the rlp input * @return the commit payload + * @throws RLPException if the received bytes are not the unique canonical encoding of the payload */ public static CommitPayload readFrom(final RLPInput rlpInput) { - rlpInput.enterList(); - final ConsensusRoundIdentifier roundIdentifier = readConsensusRound(rlpInput); - final Hash digest = Payload.readDigest(rlpInput); + // Take the payload as a self-contained RLP item so the EXACT bytes received for it are + // available for the canonicality check below. This also advances the enclosing input past the + // payload, exactly as the previous enterList/leaveList pair did. + final RLPInput payloadRlp = rlpInput.readAsRlp(); + final Bytes received = payloadRlp.raw(); + + payloadRlp.enterList(); + final ConsensusRoundIdentifier roundIdentifier = readConsensusRound(payloadRlp); + final Hash digest = Payload.readDigest(payloadRlp); final SECPSignature commitSeal = - rlpInput.readBytes(SignatureAlgorithmFactory.getInstance()::decodeSignature); - rlpInput.leaveList(); + payloadRlp.readBytes(SignatureAlgorithmFactory.getInstance()::decodeSignature); + + // AERE hybrid PQC: an OPTIONAL parallel Falcon-512 seal [index, sig] may follow the ECDSA seal. + // A Falcon-less commit ends the list here and decodes to Optional.empty(). + Optional falconSeal = Optional.empty(); + if (!payloadRlp.isEndOfCurrentList()) { + payloadRlp.enterList(); + final int idx = payloadRlp.readIntScalar(); + final Bytes sig = payloadRlp.readBytes(); + payloadRlp.leaveList(); + falconSeal = Optional.of(new FalconSeal(idx, sig)); + } + + // AERE HIBRID: a SECOND optional element, the non-Falcon scheme seals. Unambiguous by count: + // it can only be here if the Falcon element above was already consumed, so the two slots can + // never be confused for one another and no structural sniffing is required. + List extraSeals = List.of(); + if (!payloadRlp.isEndOfCurrentList()) { + final Bytes extrasRaw = payloadRlp.readAsRlp().raw(); + try { + extraSeals = PqAnchorV2.decode(extrasRaw); + } catch (final RuntimeException e) { + // A malformed certificate is a malformed MESSAGE. It must surface as an RLP failure so the + // gossip layer drops it like any other undecodable commit, never as an unchecked throw on + // the consensus path. + throw new RLPException("AERE HIBRID: undecodable extra certificate: " + e.getMessage()); + } + if (extraSeals.isEmpty()) { + // An empty extras element and an absent one would be two encodings of the same value. + throw new RLPException("AERE HIBRID: empty extra certificate must be absent, not empty"); + } + } + payloadRlp.leaveList(); + + final CommitPayload payload; + try { + payload = + new CommitPayload(roundIdentifier, digest, commitSeal, falconSeal, extraSeals); + } catch (final IllegalArgumentException e) { + // The constructor's invariants (Falcon not in the extras, extras imply Falcon) are part of + // what a valid message is, so a violation is a decode failure, not a crash. + throw new RLPException("AERE HIBRID: " + e.getMessage()); + } - return new CommitPayload(roundIdentifier, digest, commitSeal); + // AERE FIX-MALEABILITATE: exactly one encoding is accepted for a given payload value. This + // catches everything the RLP reader itself would tolerate, including any element the decode + // path above does not consume, and any encoding difference the reader does not consider an + // error. Cheap: one re-encode of a message that is already about to be keccak hashed for + // signature recovery. + final Bytes reencoded = payload.encoded(); + if (!reencoded.equals(received)) { + throw new RLPException( + "Non-canonical Commit payload encoding: received " + + received.size() + + " bytes, canonical form is " + + reencoded.size() + + " bytes"); + } + + return payload; } @Override @@ -72,6 +275,23 @@ public class CommitPayload extends QbftPayload { writeConsensusRound(rlpOutput); rlpOutput.writeBytes(digest.getBytes()); rlpOutput.writeBytes(commitSeal.encodedBytes()); + // AERE hybrid PQC: append the parallel Falcon seal only when present, so a Falcon-less commit is + // byte-identical to upstream Besu. The author's ECDSA signature covers this element. + // This method defines THE canonical encoding: readFrom rejects anything that does not + // reproduce byte for byte what this method writes. + if (falconSeal.isPresent()) { + final FalconSeal fs = falconSeal.get(); + rlpOutput.startList(); + rlpOutput.writeIntScalar(fs.getValidatorIndex()); + rlpOutput.writeBytes(fs.getSignature()); + rlpOutput.endList(); + } + // AERE HIBRID: the extras, only when there are any. Absent extras leave the encoding of a + // Falcon-only commit byte-for-byte as it is on the live fleet today, which is locked by a + // golden vector in CommitPayloadHybridTest. + if (!extraSeals.isEmpty()) { + rlpOutput.writeRaw(PqAnchorV2.encode(extraSeals)); + } rlpOutput.endList(); } @@ -98,6 +318,24 @@ public class CommitPayload extends QbftPayload { return commitSeal; } + /** + * Gets the optional parallel post-quantum Falcon seal. + * + * @return the Falcon seal if this commit carried one, otherwise empty + */ + public Optional getFalconSeal() { + return falconSeal; + } + + /** + * Gets the non-Falcon scheme seals of a hybrid certificate. + * + * @return the extra seals, empty for every commit the live fleet emits today + */ + public List getExtraSeals() { + return extraSeals; + } + @Override public ConsensusRoundIdentifier getRoundIdentifier() { return roundIdentifier; @@ -114,12 +352,14 @@ public class CommitPayload extends QbftPayload { final CommitPayload that = (CommitPayload) o; return Objects.equals(roundIdentifier, that.roundIdentifier) && Objects.equals(digest, that.digest) - && Objects.equals(commitSeal, that.commitSeal); + && Objects.equals(commitSeal, that.commitSeal) + && Objects.equals(falconSeal, that.falconSeal) + && Objects.equals(extraSeals, that.extraSeals); } @Override public int hashCode() { - return Objects.hash(roundIdentifier, digest, commitSeal); + return Objects.hash(roundIdentifier, digest, commitSeal, falconSeal, extraSeals); } @Override @@ -128,6 +368,8 @@ public class CommitPayload extends QbftPayload { .add("roundIdentifier=" + roundIdentifier) .add("digest=" + digest) .add("commitSeal=" + commitSeal) + .add("falconSeal=" + falconSeal) + .add("extraSeals=" + extraSeals.size()) .toString(); } } diff --git a/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/MessageFactory.java b/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/MessageFactory.java index 1bcd73ae9..11426d6a6 100644 --- a/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/MessageFactory.java +++ b/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/MessageFactory.java @@ -11,10 +11,17 @@ * specific language governing permissions and limitations under the License. * * SPDX-License-Identifier: Apache-2.0 + * + * Modifications Copyright contributors to the Aere Network. + * + * This file was modified by contributors to the Aere Network, as required by section 4(b) of the + * Apache License 2.0. The copyright header above is the upstream one and is left exactly as it was + * found, as section 4(c) requires. The change itself is in patches/0003-aere-pq-anchor.patch. */ package org.hyperledger.besu.consensus.qbft.core.payload; import org.hyperledger.besu.consensus.common.bft.ConsensusRoundIdentifier; +import org.hyperledger.besu.consensus.common.bft.FalconSeal; import org.hyperledger.besu.consensus.common.bft.payload.Payload; import org.hyperledger.besu.consensus.common.bft.payload.SignedData; import org.hyperledger.besu.consensus.qbft.core.messagewrappers.Commit; @@ -75,6 +82,35 @@ public class MessageFactory { return new Proposal(createSignedMessage(payload), roundChanges, prepares); } + /** + * Create proposal carrying the proposer's optional post-quantum seal (AERE PQ, 2026-08-30). + * + *

The seal rides INSIDE the signed payload, so the proposer's ECDSA signature covers it and + * nobody can strip or replace it without failing authorship recovery. With the seal absent this + * overload produces byte-for-byte what the upstream overload produces. + * + * @param roundIdentifier the round identifier + * @param block the block + * @param blockAccessList the block access list + * @param roundChanges the round changes + * @param prepares the prepares + * @param falconSeal the proposer's post-quantum seal, or empty + * @return the proposal + */ + public Proposal createProposal( + final ConsensusRoundIdentifier roundIdentifier, + final QbftBlock block, + final Optional blockAccessList, + final List> roundChanges, + final List> prepares, + final Optional falconSeal) { + + final ProposalPayload payload = + new ProposalPayload(roundIdentifier, block, blockEncoder, blockAccessList, falconSeal); + + return new Proposal(createSignedMessage(payload), roundChanges, prepares); + } + /** * Create proposal. * @@ -100,7 +136,27 @@ public class MessageFactory { * @return the prepare */ public Prepare createPrepare(final ConsensusRoundIdentifier roundIdentifier, final Hash digest) { - final PreparePayload payload = new PreparePayload(roundIdentifier, digest); + return createPrepare(roundIdentifier, digest, Optional.empty()); + } + + /** + * Create a Prepare carrying an OPTIONAL post-quantum seal of its author. + * + *

AERE PQ (2026-08-28), pasul 1: firul poate purta sigiliul, si nimic nu il emite inca - + * fiecare apel de azi trece prin varianta fara sigiliu de mai sus. Ca la commit, semnatura ECDSA + * a autorului acopera INTREG payload-ul, deci si sigiliul, ceea ce leaga indexul revendicat de + * identitatea celui care trimite mesajul. + * + * @param roundIdentifier the round identifier + * @param digest the digest + * @param falconSeal the author's post-quantum seal, or empty + * @return the prepare + */ + public Prepare createPrepare( + final ConsensusRoundIdentifier roundIdentifier, + final Hash digest, + final Optional falconSeal) { + final PreparePayload payload = new PreparePayload(roundIdentifier, digest, falconSeal); return new Prepare(createSignedMessage(payload)); } @@ -116,7 +172,47 @@ public class MessageFactory { final ConsensusRoundIdentifier roundIdentifier, final Hash digest, final SECPSignature commitSeal) { - final CommitPayload payload = new CommitPayload(roundIdentifier, digest, commitSeal); + return createCommit(roundIdentifier, digest, commitSeal, Optional.empty()); + } + + /** + * Create commit payload carrying an optional parallel Falcon-512 seal. The whole payload + * (including the Falcon seal) is signed by this node's ECDSA key. + * + * @param roundIdentifier the round identifier + * @param digest the digest + * @param commitSeal the ECDSA commit seal + * @param falconSeal the optional parallel post-quantum Falcon seal over the same commit hash + * @return the commit + */ + public Commit createCommit( + final ConsensusRoundIdentifier roundIdentifier, + final Hash digest, + final SECPSignature commitSeal, + final Optional falconSeal) { + return createCommit(roundIdentifier, digest, commitSeal, falconSeal, java.util.List.of()); + } + + /** + * Create a commit carrying a HYBRID post-quantum certificate: the Falcon seal in its own slot + * plus the other schemes alongside it. The whole payload, extras included, is signed by this + * node's ECDSA key, so the extras cannot be added or stripped by anyone else. + * + * @param roundIdentifier the round identifier + * @param digest the digest + * @param commitSeal the ECDSA commit seal + * @param falconSeal the Falcon seal; required whenever extras are present + * @param extraSeals the non-Falcon scheme seals + * @return the commit + */ + public Commit createCommit( + final ConsensusRoundIdentifier roundIdentifier, + final Hash digest, + final SECPSignature commitSeal, + final Optional falconSeal, + final java.util.List extraSeals) { + final CommitPayload payload = + new CommitPayload(roundIdentifier, digest, commitSeal, falconSeal, extraSeals); return new Commit(createSignedMessage(payload)); } @@ -130,6 +226,25 @@ public class MessageFactory { public RoundChange createRoundChange( final ConsensusRoundIdentifier roundIdentifier, final Optional preparedRoundData) { + return createRoundChange(roundIdentifier, preparedRoundData, Optional.empty()); + } + + /** + * Create round change carrying the author's post-quantum seal. AERE PQ (2026-08-31): the sealed + * twin of the method above, exactly as at PREPARE and PROPOSAL. With the gate shut every call of + * today goes through the seal-less form, which encodes byte for byte as upstream. The author's + * ECDSA signature covers the WHOLE payload, seal included, which binds the claimed index to the + * identity of the sender. + * + * @param roundIdentifier the round identifier + * @param preparedRoundData the prepared round data + * @param falconSeal the author's post-quantum seal, or empty + * @return the round change + */ + public RoundChange createRoundChange( + final ConsensusRoundIdentifier roundIdentifier, + final Optional preparedRoundData, + final Optional falconSeal) { final RoundChangePayload payload; if (preparedRoundData.isPresent()) { @@ -140,7 +255,8 @@ public class MessageFactory { roundIdentifier, Optional.of( new PreparedRoundMetadata( - preparedBlock.getHash(), preparedRoundData.get().getRound()))); + preparedBlock.getHash(), preparedRoundData.get().getRound())), + falconSeal); return new RoundChange( createSignedMessage(payload), @@ -150,7 +266,7 @@ public class MessageFactory { preparedRoundData.get().getPrepares()); } else { - payload = new RoundChangePayload(roundIdentifier, Optional.empty()); + payload = new RoundChangePayload(roundIdentifier, Optional.empty(), falconSeal); return new RoundChange( createSignedMessage(payload), Optional.empty(), diff --git a/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/PreparePayload.java b/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/PreparePayload.java index b9d53d35f..727cbccc8 100644 --- a/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/PreparePayload.java +++ b/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/PreparePayload.java @@ -11,48 +11,127 @@ * specific language governing permissions and limitations under the License. * * SPDX-License-Identifier: Apache-2.0 + * + * Modifications Copyright contributors to the Aere Network. + * + * This file was modified by contributors to the Aere Network, as required by section 4(b) of the + * Apache License 2.0. The copyright header above is the upstream one and is left exactly as it was + * found, as section 4(c) requires. The change itself is in patches/0003-aere-pq-anchor.patch. */ package org.hyperledger.besu.consensus.qbft.core.payload; import org.hyperledger.besu.consensus.common.bft.ConsensusRoundIdentifier; +import org.hyperledger.besu.consensus.common.bft.FalconSeal; import org.hyperledger.besu.consensus.common.bft.payload.Payload; import org.hyperledger.besu.consensus.qbft.core.messagedata.QbftV1; import org.hyperledger.besu.datatypes.Hash; +import org.hyperledger.besu.ethereum.rlp.RLPException; import org.hyperledger.besu.ethereum.rlp.RLPInput; import org.hyperledger.besu.ethereum.rlp.RLPOutput; import java.util.Objects; +import java.util.Optional; import java.util.StringJoiner; -/** The Prepare payload. */ +import org.apache.tuweni.bytes.Bytes; + +/** + * The Prepare payload. + * + *

AERE PQ (2026-08-28), step 1 of PREPARE-SI-ROUNDCHANGE-SUB-PQ-PROIECTARE-2026-08-28: a PREPARE + * MAY carry an OPTIONAL Falcon-512 seal from its author, appended at the end, exactly as + * {@code CommitPayload} does. A PREPARE without a seal encodes byte for byte as upstream, + * and that is precisely the property that lets the binary be rolled onto a live fleet without a + * flag day. + * + *

NOTHING EMITS SUCH A PREPARE YET. This file only makes the wire capable of carrying one + * and of refusing a malformed one. Emission is the next step and has its own gate, following the + * rule paid for at commit: first the binary everywhere, then emission, and only much later + * enforcement. + * + *

What the seal signs is NOT this file's business, and the design note states it: its own + * domain {@code AERE-PQ-PREPARE-1} over (chainId, number, ROUND, digest). If it signed the same + * bytes as a commit seal, a PREPARE seal given honestly could be pasted onto a forged COMMIT and + * the enforcement there would accept it. + */ public class PreparePayload extends QbftPayload { private static final int TYPE = QbftV1.PREPARE; private final ConsensusRoundIdentifier roundIdentifier; private final Hash digest; + private final Optional falconSeal; /** - * Instantiates a new Prepare payload. + * Instantiates a new Prepare payload, without a post-quantum seal. Encodes byte-for-byte as + * upstream Besu. * * @param roundIdentifier the round identifier * @param digest the digest */ public PreparePayload(final ConsensusRoundIdentifier roundIdentifier, final Hash digest) { + this(roundIdentifier, digest, Optional.empty()); + } + + /** + * Instantiates a new Prepare payload carrying an optional Falcon-512 seal of its author. + * + * @param roundIdentifier the round identifier + * @param digest the digest + * @param falconSeal the author's post-quantum seal, or empty + */ + public PreparePayload( + final ConsensusRoundIdentifier roundIdentifier, + final Hash digest, + final Optional falconSeal) { this.roundIdentifier = roundIdentifier; this.digest = digest; + this.falconSeal = falconSeal == null ? Optional.empty() : falconSeal; } /** * Read from rlp input and return prepare payload. * + *

STRICTLY CANONICAL, as in {@code CommitPayload} and for the same reason: a PREPARE is an + * AUTHENTICATED message, and the author is recovered from the RE-ENCODED payload, not from the + * bytes that arrived. Anything the decoder tolerated silently would give several byte strings that + * authenticate to the same validator - that is malleability. Decode, re-encode, and the result + * must be exactly what came in. + * * @param rlpInput the rlp input * @return the prepare payload + * @throws RLPException if the received bytes are not the payload's unique canonical encoding */ public static PreparePayload readFrom(final RLPInput rlpInput) { - rlpInput.enterList(); - final ConsensusRoundIdentifier roundIdentifier = readConsensusRound(rlpInput); - final Hash digest = Payload.readDigest(rlpInput); - rlpInput.leaveList(); - return new PreparePayload(roundIdentifier, digest); + final RLPInput payloadRlp = rlpInput.readAsRlp(); + final Bytes received = payloadRlp.raw(); + + payloadRlp.enterList(); + final ConsensusRoundIdentifier roundIdentifier = readConsensusRound(payloadRlp); + final Hash digest = Payload.readDigest(payloadRlp); + + // AERE PQ: the OPTIONAL seal [index, signature]. A PREPARE without one ends the list here and + // decodes to Optional.empty(), so it stays identical to upstream. + Optional falconSeal = Optional.empty(); + if (!payloadRlp.isEndOfCurrentList()) { + payloadRlp.enterList(); + final int idx = payloadRlp.readIntScalar(); + final Bytes sig = payloadRlp.readBytes(); + payloadRlp.leaveList(); + falconSeal = Optional.of(new FalconSeal(idx, sig)); + } + payloadRlp.leaveList(); + + final PreparePayload payload = new PreparePayload(roundIdentifier, digest, falconSeal); + + final Bytes reencoded = payload.encoded(); + if (!reencoded.equals(received)) { + throw new RLPException( + "Non-canonical Prepare payload encoding: received " + + received.size() + + " bytes, canonical form is " + + reencoded.size() + + " bytes"); + } + return payload; } @Override @@ -60,6 +139,16 @@ public class PreparePayload extends QbftPayload { rlpOutput.startList(); writeConsensusRound(rlpOutput); rlpOutput.writeBytes(digest.getBytes()); + // This method DEFINES the canonical encoding: readFrom refuses anything that does not reproduce + // it byte for byte. The seal is written only when present, so a seal-less PREPARE is identical + // to upstream. + if (falconSeal.isPresent()) { + final FalconSeal fs = falconSeal.get(); + rlpOutput.startList(); + rlpOutput.writeIntScalar(fs.getValidatorIndex()); + rlpOutput.writeBytes(fs.getSignature()); + rlpOutput.endList(); + } rlpOutput.endList(); } @@ -77,6 +166,15 @@ public class PreparePayload extends QbftPayload { return digest; } + /** + * The author's post-quantum seal, when the message carries one. + * + * @return the seal, or empty + */ + public Optional getFalconSeal() { + return falconSeal; + } + @Override public ConsensusRoundIdentifier getRoundIdentifier() { return roundIdentifier; @@ -92,12 +190,13 @@ public class PreparePayload extends QbftPayload { } final PreparePayload that = (PreparePayload) o; return Objects.equals(roundIdentifier, that.roundIdentifier) - && Objects.equals(digest, that.digest); + && Objects.equals(digest, that.digest) + && Objects.equals(falconSeal, that.falconSeal); } @Override public int hashCode() { - return Objects.hash(roundIdentifier, digest); + return Objects.hash(roundIdentifier, digest, falconSeal); } @Override @@ -105,6 +204,7 @@ public class PreparePayload extends QbftPayload { return new StringJoiner(", ", PreparePayload.class.getSimpleName() + "[", "]") .add("roundIdentifier=" + roundIdentifier) .add("digest=" + digest) + .add("falconSeal=" + (falconSeal.isPresent() ? "present" : "absent")) .toString(); } } diff --git a/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/ProposalPayload.java b/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/ProposalPayload.java index eb1a2246c..813e798a9 100644 --- a/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/ProposalPayload.java +++ b/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/ProposalPayload.java @@ -11,15 +11,23 @@ * specific language governing permissions and limitations under the License. * * SPDX-License-Identifier: Apache-2.0 + * + * Modifications Copyright contributors to the Aere Network. + * + * This file was modified by contributors to the Aere Network, as required by section 4(b) of the + * Apache License 2.0. The copyright header above is the upstream one and is left exactly as it was + * found, as section 4(c) requires. The change itself is in patches/0003-aere-pq-anchor.patch. */ package org.hyperledger.besu.consensus.qbft.core.payload; import org.hyperledger.besu.consensus.common.bft.ConsensusRoundIdentifier; +import org.hyperledger.besu.consensus.common.bft.FalconSeal; import org.hyperledger.besu.consensus.qbft.core.messagedata.QbftV1; import org.hyperledger.besu.consensus.qbft.core.types.QbftBlock; import org.hyperledger.besu.consensus.qbft.core.types.QbftBlockCodec; import org.hyperledger.besu.ethereum.core.encoding.BlockAccessListDecoder; import org.hyperledger.besu.ethereum.mainnet.block.access.list.BlockAccessList; +import org.hyperledger.besu.ethereum.rlp.RLPException; import org.hyperledger.besu.ethereum.rlp.RLPInput; import org.hyperledger.besu.ethereum.rlp.RLPOutput; @@ -28,7 +36,33 @@ import java.util.Optional; import com.google.common.base.MoreObjects; -/** The Proposal payload. */ +/** + * The Proposal payload. + * + *

AERE PQ (2026-08-30), the hot-path step after PREPARE: a PROPOSAL MAY carry an OPTIONAL + * Falcon-512 seal from its proposer, appended after the block-access-list element, exactly as + * {@code PreparePayload} appends its seal. A proposal without a seal encodes byte for byte as + * upstream, which is the property that lets the binary be rolled onto a live fleet without a + * flag day. + * + *

NOTHING EMITS SUCH A PROPOSAL YET. This file only makes the wire capable of carrying + * one and of refusing a malformed one. Emission has its own gate + * ({@code aere.pq.proposalPq.attachBlock}); enforcement has its own height + * ({@code aere.pq.proposalPq.forkBlock}); both are absent on every node today, and absent means + * never. + * + *

What the seal signs is NOT this file's business: its own domain + * {@code AERE-PQ-PROPOSAL-1} over (chainId, height, ROUND, digest) - see + * {@code PqAnchor.proposalMessage}. A proposal is an offer, not a vote: with the commit or PREPARE + * domain, a proposal seal given honestly could be pasted onto a forged vote and counted. + * + *

Why the trailing element is parsed strictly: the payload is AUTHENTICATED - the author + * is recovered from the signature over the encoded payload. A decoder that silently ignored an + * unknown trailing element would re-encode without it, so two different byte strings would + * authenticate to the same proposer. When a fifth element is present it must be exactly a seal, + * and it must be the last element; otherwise the message is refused. Messages of the upstream + * shapes (three or four elements) are read exactly as upstream reads them. + */ public class ProposalPayload extends QbftPayload { private static final int TYPE = QbftV1.PROPOSAL; @@ -36,24 +70,45 @@ public class ProposalPayload extends QbftPayload { private final QbftBlock proposedBlock; private final QbftBlockCodec blockEncoder; private final Optional blockAccessList; + private final Optional falconSeal; /** - * Instantiates a new Proposal payload. + * Instantiates a new Proposal payload carrying an optional post-quantum seal of its proposer. * * @param roundIdentifier the round identifier * @param proposedBlock the proposed block * @param blockEncoder the qbft block encoder * @param blockAccessList the block access list + * @param falconSeal the proposer's post-quantum seal, or empty */ public ProposalPayload( final ConsensusRoundIdentifier roundIdentifier, final QbftBlock proposedBlock, final QbftBlockCodec blockEncoder, - final Optional blockAccessList) { + final Optional blockAccessList, + final Optional falconSeal) { this.roundIdentifier = roundIdentifier; this.proposedBlock = proposedBlock; this.blockEncoder = blockEncoder; this.blockAccessList = blockAccessList; + this.falconSeal = falconSeal == null ? Optional.empty() : falconSeal; + } + + /** + * Instantiates a new Proposal payload, without a post-quantum seal. Encodes byte-for-byte as + * upstream Besu. + * + * @param roundIdentifier the round identifier + * @param proposedBlock the proposed block + * @param blockEncoder the qbft block encoder + * @param blockAccessList the block access list + */ + public ProposalPayload( + final ConsensusRoundIdentifier roundIdentifier, + final QbftBlock proposedBlock, + final QbftBlockCodec blockEncoder, + final Optional blockAccessList) { + this(roundIdentifier, proposedBlock, blockEncoder, blockAccessList, Optional.empty()); } /** @@ -67,7 +122,7 @@ public class ProposalPayload extends QbftPayload { final ConsensusRoundIdentifier roundIdentifier, final QbftBlock proposedBlock, final QbftBlockCodec blockEncoder) { - this(roundIdentifier, proposedBlock, blockEncoder, Optional.empty()); + this(roundIdentifier, proposedBlock, blockEncoder, Optional.empty(), Optional.empty()); } /** @@ -83,9 +138,28 @@ public class ProposalPayload extends QbftPayload { final ConsensusRoundIdentifier roundIdentifier = readConsensusRound(rlpInput); final QbftBlock proposedBlock = blockEncoder.readFrom(rlpInput); final Optional blockAccessList = readBlockAccessList(rlpInput); + + // AERE PQ: the OPTIONAL proposer seal [index, signature]. A proposal without one ends the list + // here and decodes to Optional.empty(), so the upstream shapes stay untouched. When a fifth + // element exists it must be a seal and it must be last: an unknown trailing element on an + // authenticated payload is malleability, not extensibility. + Optional falconSeal = Optional.empty(); + if (!rlpInput.isEndOfCurrentList()) { + rlpInput.enterList(); + final int idx = rlpInput.readIntScalar(); + final org.apache.tuweni.bytes.Bytes sig = rlpInput.readBytes(); + rlpInput.leaveList(); + falconSeal = Optional.of(new FalconSeal(idx, sig)); + if (!rlpInput.isEndOfCurrentList()) { + throw new RLPException( + "Proposal payload carries elements after the proposer seal; refusing an encoding the " + + "re-encoder would silently drop"); + } + } rlpInput.leaveList(); - return new ProposalPayload(roundIdentifier, proposedBlock, blockEncoder, blockAccessList); + return new ProposalPayload( + roundIdentifier, proposedBlock, blockEncoder, blockAccessList, falconSeal); } @Override @@ -94,6 +168,14 @@ public class ProposalPayload extends QbftPayload { writeConsensusRound(rlpOutput); blockEncoder.writeTo(proposedBlock, rlpOutput); blockAccessList.ifPresentOrElse((bal) -> bal.writeTo(rlpOutput), rlpOutput::writeNull); + // The seal is written only when present, so a seal-less proposal is identical to upstream. + if (falconSeal.isPresent()) { + final FalconSeal fs = falconSeal.get(); + rlpOutput.startList(); + rlpOutput.writeIntScalar(fs.getValidatorIndex()); + rlpOutput.writeBytes(fs.getSignature()); + rlpOutput.endList(); + } rlpOutput.endList(); } @@ -115,6 +197,15 @@ public class ProposalPayload extends QbftPayload { return blockAccessList; } + /** + * The proposer's post-quantum seal, when the message carries one. + * + * @return the seal, or empty + */ + public Optional getFalconSeal() { + return falconSeal; + } + @Override public int getMessageType() { return TYPE; @@ -136,12 +227,13 @@ public class ProposalPayload extends QbftPayload { ProposalPayload that = (ProposalPayload) o; return Objects.equals(roundIdentifier, that.roundIdentifier) && Objects.equals(proposedBlock, that.proposedBlock) - && Objects.equals(blockAccessList, that.blockAccessList); + && Objects.equals(blockAccessList, that.blockAccessList) + && Objects.equals(falconSeal, that.falconSeal); } @Override public int hashCode() { - return Objects.hash(roundIdentifier, proposedBlock, blockAccessList); + return Objects.hash(roundIdentifier, proposedBlock, blockAccessList, falconSeal); } @Override @@ -150,6 +242,7 @@ public class ProposalPayload extends QbftPayload { .add("roundIdentifier", roundIdentifier) .add("proposedBlock", proposedBlock) .add("blockAccessList", blockAccessList) + .add("falconSeal", falconSeal.isPresent() ? "present" : "absent") .toString(); } diff --git a/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/RoundChangePayload.java b/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/RoundChangePayload.java index 9f4d9abed..8804b6a46 100644 --- a/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/RoundChangePayload.java +++ b/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/RoundChangePayload.java @@ -11,11 +11,19 @@ * specific language governing permissions and limitations under the License. * * SPDX-License-Identifier: Apache-2.0 + * + * Modifications Copyright contributors to the Aere Network. + * + * This file was modified by contributors to the Aere Network, as required by section 4(b) of the + * Apache License 2.0. The copyright header above is the upstream one and is left exactly as it was + * found, as section 4(c) requires. The change itself is in patches/0003-aere-pq-anchor.patch. */ package org.hyperledger.besu.consensus.qbft.core.payload; import org.hyperledger.besu.consensus.common.bft.ConsensusRoundIdentifier; +import org.hyperledger.besu.consensus.common.bft.FalconSeal; import org.hyperledger.besu.consensus.qbft.core.messagedata.QbftV1; +import org.hyperledger.besu.ethereum.rlp.RLPException; import org.hyperledger.besu.ethereum.rlp.RLPInput; import org.hyperledger.besu.ethereum.rlp.RLPOutput; @@ -24,23 +32,68 @@ import java.util.Optional; import com.google.common.base.MoreObjects; -/** The Round change payload. */ +/** + * The Round change payload. + * + *

AERE PQ (2026-08-31), the LAST hot-path message: a ROUND-CHANGE MAY carry an OPTIONAL + * Falcon-512 seal from its author, appended after the prepared-metadata list, exactly as {@code + * ProposalPayload} appends its seal. A round-change without a seal encodes byte for byte as + * upstream, which is the property that lets the binary be rolled onto a live fleet without a + * flag day. + * + *

NOTHING EMITS SUCH A ROUND-CHANGE YET. This file only makes the wire capable of + * carrying one and of refusing a malformed one. Emission has its own gate + * ({@code aere.pq.roundChangePq.attachBlock}); enforcement has its own height + * ({@code aere.pq.roundChangePq.forkBlock}); both are absent on every node today, and absent means + * never. + * + *

What the seal signs is NOT this file's business: its own domain + * {@code AERE-PQ-ROUNDCHANGE-1} over (chainId, height, targetRound, prepared metadata) - see + * {@code PqAnchor.roundChangeMessage}. A round-change STEERS rounds: a quorum of them opens a new + * round, and one claiming a prepared block decides which block gets re-proposed. Its seal must be + * transferable neither onto a vote nor onto a proposal, nor between a bare round-change and one + * with metadata. + * + *

Why the trailing element is parsed strictly: the payload is AUTHENTICATED - the author + * is recovered from the signature over the encoded payload. A decoder that silently ignored an + * unknown trailing element would re-encode without it, so two different byte strings would + * authenticate to the same author. When a fourth element is present it must be exactly a seal, and + * it must be the last element; otherwise the message is refused. Messages of the upstream shape + * (three elements) are read exactly as upstream reads them. + */ public class RoundChangePayload extends QbftPayload { private static final int TYPE = QbftV1.ROUND_CHANGE; private final ConsensusRoundIdentifier roundChangeIdentifier; private final Optional preparedRoundMetadata; + private final Optional falconSeal; /** - * Instantiates a new Round change payload. + * Instantiates a new Round change payload carrying an optional post-quantum seal of its author. * * @param roundChangeIdentifier the round change identifier * @param preparedRoundMetadata the prepared round metadata + * @param falconSeal the author's post-quantum seal, or empty */ public RoundChangePayload( final ConsensusRoundIdentifier roundChangeIdentifier, - final Optional preparedRoundMetadata) { + final Optional preparedRoundMetadata, + final Optional falconSeal) { this.roundChangeIdentifier = roundChangeIdentifier; this.preparedRoundMetadata = preparedRoundMetadata; + this.falconSeal = falconSeal == null ? Optional.empty() : falconSeal; + } + + /** + * Instantiates a new Round change payload, without a post-quantum seal. Encodes byte-for-byte as + * upstream Besu. + * + * @param roundChangeIdentifier the round change identifier + * @param preparedRoundMetadata the prepared round metadata + */ + public RoundChangePayload( + final ConsensusRoundIdentifier roundChangeIdentifier, + final Optional preparedRoundMetadata) { + this(roundChangeIdentifier, preparedRoundMetadata, Optional.empty()); } @Override @@ -57,6 +110,15 @@ public class RoundChangePayload extends QbftPayload { return preparedRoundMetadata; } + /** + * The author's post-quantum seal, when the message carries one. + * + * @return the seal, or empty + */ + public Optional getFalconSeal() { + return falconSeal; + } + @Override public void writeTo(final RLPOutput rlpOutput) { // RLP encode of the message data content (round identifier and prepared certificate) @@ -67,6 +129,15 @@ public class RoundChangePayload extends QbftPayload { preparedRoundMetadata.ifPresent(prm -> prm.writeTo(rlpOutput)); rlpOutput.endList(); + // The seal is written only when present, so a seal-less round-change is identical to upstream. + if (falconSeal.isPresent()) { + final FalconSeal fs = falconSeal.get(); + rlpOutput.startList(); + rlpOutput.writeIntScalar(fs.getValidatorIndex()); + rlpOutput.writeBytes(fs.getSignature()); + rlpOutput.endList(); + } + rlpOutput.endList(); } @@ -89,8 +160,26 @@ public class RoundChangePayload extends QbftPayload { } rlpInput.leaveList(); + // AERE PQ: the OPTIONAL author seal [index, signature]. A round-change without one ends the + // list here and decodes to Optional.empty(), so the upstream shape stays untouched. When a + // fourth element exists it must be a seal and it must be last: an unknown trailing element on + // an authenticated payload is malleability, not extensibility. + Optional falconSeal = Optional.empty(); + if (!rlpInput.isEndOfCurrentList()) { + rlpInput.enterList(); + final int idx = rlpInput.readIntScalar(); + final org.apache.tuweni.bytes.Bytes sig = rlpInput.readBytes(); + rlpInput.leaveList(); + falconSeal = Optional.of(new FalconSeal(idx, sig)); + if (!rlpInput.isEndOfCurrentList()) { + throw new RLPException( + "RoundChange payload carries elements after the author seal; refusing an encoding the " + + "re-encoder would silently drop"); + } + } + rlpInput.leaveList(); - return new RoundChangePayload(roundIdentifier, preparedRoundMetadata); + return new RoundChangePayload(roundIdentifier, preparedRoundMetadata, falconSeal); } @Override @@ -108,12 +197,13 @@ public class RoundChangePayload extends QbftPayload { } RoundChangePayload that = (RoundChangePayload) o; return Objects.equals(roundChangeIdentifier, that.roundChangeIdentifier) - && Objects.equals(preparedRoundMetadata, that.preparedRoundMetadata); + && Objects.equals(preparedRoundMetadata, that.preparedRoundMetadata) + && Objects.equals(falconSeal, that.falconSeal); } @Override public int hashCode() { - return Objects.hash(roundChangeIdentifier, preparedRoundMetadata); + return Objects.hash(roundChangeIdentifier, preparedRoundMetadata, falconSeal); } @Override @@ -121,6 +211,7 @@ public class RoundChangePayload extends QbftPayload { return MoreObjects.toStringHelper(this) .add("roundChangeIdentifier", roundChangeIdentifier) .add("preparedRoundMetadata", preparedRoundMetadata) + .add("falconSeal", falconSeal.isPresent() ? "present" : "absent") .toString(); } } diff --git a/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/QbftBlockHeightManager.java b/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/QbftBlockHeightManager.java index 49099c2c1..bc404409c 100644 --- a/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/QbftBlockHeightManager.java +++ b/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/QbftBlockHeightManager.java @@ -11,12 +11,19 @@ * specific language governing permissions and limitations under the License. * * SPDX-License-Identifier: Apache-2.0 + * + * Modifications Copyright contributors to the Aere Network. + * + * This file was modified by contributors to the Aere Network, as required by section 4(b) of the + * Apache License 2.0. The copyright header above is the upstream one and is left exactly as it was + * found, as section 4(c) requires. The change itself is in patches/0003-aere-pq-anchor.patch. */ package org.hyperledger.besu.consensus.qbft.core.statemachine; import org.hyperledger.besu.consensus.common.bft.ConsensusRoundIdentifier; import org.hyperledger.besu.consensus.common.bft.events.RoundExpiry; import org.hyperledger.besu.consensus.common.bft.messagewrappers.BftMessage; +import org.hyperledger.besu.consensus.common.bft.PqAnchorNotReadyException; import org.hyperledger.besu.consensus.common.bft.payload.Payload; import org.hyperledger.besu.consensus.qbft.core.messagewrappers.Commit; import org.hyperledger.besu.consensus.qbft.core.messagewrappers.Prepare; @@ -200,8 +207,26 @@ public class QbftBlockHeightManager implements BaseQbftBlockHeightManager { } final long headerTimeStampSeconds = Math.round(clock.millis() / 1000D); - final QbftBlockCreator.BlockCreationResult blockCreationResult = - qbftRound.createBlock(headerTimeStampSeconds); + final QbftBlockCreator.BlockCreationResult blockCreationResult; + try { + blockCreationResult = qbftRound.createBlock(headerTimeStampSeconds); + } catch (final RuntimeException thrown) { + // MEASURED 2026-08-02: see PqAnchorNotReadyException.findIn. The refusal arrives wrapped in + // IllegalStateException from AbstractBlockCreator, so the narrow catch never fired. Anything + // that is not a refusal is rethrown untouched. + final PqAnchorNotReadyException e = PqAnchorNotReadyException.findIn(thrown); + if (e == null) { + throw thrown; + } + // AERE ANCHOR V2: this node is the proposer but cannot back the block with a Falcon quorum + // certificate that reaches the staged threshold K, so it does NOT propose. The round times + // out and the next proposer takes over. Proposing anyway would emit a header this node has + // already computed to be invalid under its own rules, and in QBFT it would cost exactly the + // same round timer, so it would buy nothing. The usual cause is a node that has just + // restarted and holds no seals until it takes part in one commit: at most one lost turn. + LOG.warn("AERE PQ-ANCHOR: not proposing. round={}. {}", roundIdentifier, e.getMessage()); + return; + } final QbftBlock block = blockCreationResult.block(); final Optional blockAccessList = blockCreationResult.blockAccessList(); if (!block.isEmpty()) { @@ -304,10 +329,16 @@ public class QbftBlockHeightManager implements BaseQbftBlockHeightManager { } QbftRound qbftRoundNew = currentRound.get(); + RoundChange localRoundChange = null; try { - final RoundChange localRoundChange = - messageFactory.createRoundChange( - qbftRoundNew.getRoundIdentifier(), latestPreparedCertificate); + final Optional roundChangeSeal = + roundChangeSealFor(qbftRoundNew.getRoundIdentifier(), latestPreparedCertificate); + localRoundChange = + roundChangeSeal.isPresent() + ? messageFactory.createRoundChange( + qbftRoundNew.getRoundIdentifier(), latestPreparedCertificate, roundChangeSeal) + : messageFactory.createRoundChange( + qbftRoundNew.getRoundIdentifier(), latestPreparedCertificate); // Its possible the locally created RoundChange triggers the transmission of a NewRound // message - so it must be handled accordingly. @@ -316,7 +347,45 @@ public class QbftBlockHeightManager implements BaseQbftBlockHeightManager { LOG.warn("Failed to create signed RoundChange message.", e); } - transmitter.multicastRoundChange(qbftRoundNew.getRoundIdentifier(), latestPreparedCertificate); + // THE SAME seal object goes on the wire. The transmitter re-creates the round-change, so + // without this the local copy would be sealed and the wire copy not - the exact defect F84 + // measured on the PROPOSAL. And it must be the SAME object, not a second signing: Falcon + // signatures are randomised, so a re-signed wire copy would differ from the local one byte for + // byte. With the gate closed the OLD call is taken, call for call, so a node that emits + // nothing new goes through the same calls as upstream. + if (localRoundChange != null + && localRoundChange.getSignedPayload().getPayload().getFalconSeal().isPresent()) { + transmitter.multicastRoundChange(localRoundChange); + } else { + transmitter.multicastRoundChange( + qbftRoundNew.getRoundIdentifier(), latestPreparedCertificate); + } + } + + /** + * This node's own post-quantum seal for a ROUND-CHANGE it is about to emit, or empty on every + * node where the emission gate ({@code aere.pq.roundChangePq.attachBlock}) is shut - which is + * every node today. The prepared metadata goes INTO the signed message, so a seal from a bare + * round-change cannot be replayed onto one that claims a prepared block. + */ + private Optional roundChangeSealFor( + final ConsensusRoundIdentifier roundIdentifier, + final Optional preparedCertificate) { + final long height = roundIdentifier.getSequenceNumber(); + final long chainId = + org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer.config().chainId(); + final org.apache.tuweni.bytes.Bytes32 message = + preparedCertificate.isPresent() + ? org.hyperledger.besu.consensus.common.bft.PqAnchor.roundChangeMessage( + chainId, + height, + roundIdentifier.getRoundNumber(), + preparedCertificate.get().getRound(), + preparedCertificate.get().getBlock().getHash().getBytes()) + : org.hyperledger.besu.consensus.common.bft.PqAnchor.roundChangeMessage( + chainId, height, roundIdentifier.getRoundNumber()); + return org.hyperledger.besu.consensus.common.bft.FalconSealSupport.instance() + .signRoundChange(height, message); } @Override diff --git a/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/QbftController.java b/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/QbftController.java index c049ccfe9..50100fd7a 100644 --- a/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/QbftController.java +++ b/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/QbftController.java @@ -11,6 +11,12 @@ * specific language governing permissions and limitations under the License. * * SPDX-License-Identifier: Apache-2.0 + * + * Modifications Copyright contributors to the Aere Network. + * + * This file was modified by contributors to the Aere Network, as required by section 4(b) of the + * Apache License 2.0. The copyright header above is the upstream one and is left exactly as it was + * found, as section 4(c) requires. The change itself is in patches/0003-aere-pq-anchor.patch. */ package org.hyperledger.besu.consensus.qbft.core.statemachine; @@ -19,6 +25,8 @@ import static org.hyperledger.besu.consensus.qbft.core.validation.ValidatorUtil. import static org.hyperledger.besu.consensus.qbft.core.validation.ValidatorUtil.isMsgFromKnownValidator; import org.hyperledger.besu.consensus.common.bft.ConsensusRoundIdentifier; +import org.hyperledger.besu.consensus.common.bft.FalconSeal; +import org.hyperledger.besu.consensus.common.bft.PqSealCache; import org.hyperledger.besu.consensus.common.bft.MessageTracker; import org.hyperledger.besu.consensus.common.bft.events.BlockTimerExpiry; import org.hyperledger.besu.consensus.common.bft.events.RoundExpiry; @@ -42,6 +50,7 @@ import org.hyperledger.besu.consensus.qbft.core.validation.MessageValidator; import org.hyperledger.besu.consensus.qbft.core.validation.RoundChangeMessageValidator; import org.hyperledger.besu.ethereum.p2p.rlpx.wire.MessageData; +import java.util.List; import java.util.Optional; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Consumer; @@ -211,6 +220,8 @@ public class QbftController implements QbftEventHandler { // the currentHeightManager, but CAN be the same directly following import). if (bftMessage.getRoundIdentifier().getSequenceNumber() <= blockchain.getChainHeadBlockNumber()) { + // AERE D-227: before the message dies here, keep its Falcon seal if it is still useful. + pqSalvageLateSeal(bftMessage); LOG.debug( "Discarding a message which targets a height {} not above current chain height {}.", bftMessage.getRoundIdentifier().getSequenceNumber(), @@ -224,6 +235,58 @@ public class QbftController implements QbftEventHandler { } } + /** + * AERE D-227 (2026-08-14): keep the Falcon seal of a Commit that arrives AFTER its block was + * imported, instead of discarding it with the message. + * + *

Why this exists, measured on chain 2800: a block imports on the quorum-th Commit, and the + * Commits of the slowest validators consistently arrive tens of milliseconds later - after the + * height gate above starts discarding them. Their Falcon seals never reached the seal cache, so + * the proposer of the NEXT block (which reads the cache roughly half a block-period later, plenty + * of time) could never carry them. Seal circulation measured per signer: the two slowest-disk + * nodes appeared in 3% and 14% of other proposers' certificates while appearing in 100% of their + * own. The ECDSA path is unaffected either way - by the time a Commit reaches this branch its + * block is already imported. + * + *

What is deliberately NOT relaxed: the message itself still dies. Only the seal is copied + * out, and only when ALL of the following hold: the message is a Commit carrying a seal, its + * height is EXACTLY the chain head (an older seal can never be asked for again), its digest is + * the head's own hash (a losing round or a fork sibling is not ours to keep), and its author is + * a known validator (so a non-validator peer cannot write into the cache). A seal that lies + * about its signer index still cannot reach a header: the producer Falcon-verifies every cached + * seal against the anchored registry before carrying it. + */ + private void pqSalvageLateSeal(final BftMessage bftMessage) { + if (!(bftMessage instanceof Commit commit)) { + return; + } + final Optional seal = commit.getFalconSeal(); + if (seal.isEmpty()) { + return; + } + final long head = blockchain.getChainHeadBlockNumber(); + if (commit.getRoundIdentifier().getSequenceNumber() != head) { + return; + } + final QbftBlockHeader headHeader = blockchain.getChainHeadHeader(); + if (!commit.getDigest().equals(headHeader.getHash())) { + return; + } + if (!finalState.getValidators().contains(commit.getAuthor())) { + return; + } + PqSealCache.instance().record(head, headHeader.getHash(), List.of(seal.get())); + // D-339 (2026-09-04): the hybrid extras ride the SAME late commits, and until today this path + // dropped them. The anchor needs K seals of EVERY scheme, so a salvaged Falcon seal without its + // SLH-DSA twin still left the proposer short and the anchor still lost its round. + final List extras = + commit.getExtraSeals(); + if (!extras.isEmpty()) { + PqSealCache.instance().recordExtras(head, headHeader.getHash(), extras); + } + LOG.trace("AERE D-227: salvaged a late Falcon seal for imported block {}", head); + } + @Override public void handleNewBlockEvent(final QbftNewChainHead newChainHead) { final QbftBlockHeader newBlockHeader = newChainHead.newChainHeadHeader(); diff --git a/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/QbftRound.java b/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/QbftRound.java index 832149e2f..3d0138d9c 100644 --- a/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/QbftRound.java +++ b/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/QbftRound.java @@ -11,13 +11,25 @@ * specific language governing permissions and limitations under the License. * * SPDX-License-Identifier: Apache-2.0 + * + * Modifications Copyright contributors to the Aere Network. + * + * This file was modified by contributors to the Aere Network, as required by section 4(b) of the + * Apache License 2.0. The copyright header above is the upstream one and is left exactly as it was + * found, as section 4(c) requires. The change itself is in patches/0003-aere-pq-anchor.patch. */ package org.hyperledger.besu.consensus.qbft.core.statemachine; import static java.util.Collections.emptyList; import org.hyperledger.besu.consensus.common.bft.ConsensusRoundIdentifier; +import org.hyperledger.besu.consensus.common.bft.FalconSeal; +import org.hyperledger.besu.consensus.common.bft.FalconSealSupport; +import org.hyperledger.besu.consensus.common.bft.PqAnchor; +import org.hyperledger.besu.consensus.common.bft.PqAnchorNotReadyException; +import org.hyperledger.besu.consensus.common.bft.PqSealCache; import org.hyperledger.besu.consensus.common.bft.RoundTimer; +import org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer; import org.hyperledger.besu.consensus.common.bft.payload.SignedData; import org.hyperledger.besu.consensus.qbft.core.messagewrappers.Commit; import org.hyperledger.besu.consensus.qbft.core.messagewrappers.Prepare; @@ -42,6 +54,7 @@ import org.hyperledger.besu.ethereum.mainnet.block.access.list.BlockAccessList; import org.hyperledger.besu.plugin.services.securitymodule.SecurityModuleException; import org.hyperledger.besu.util.Subscribers; +import java.util.Collection; import java.util.List; import java.util.Optional; @@ -75,6 +88,22 @@ public class QbftRound { private final QbftBlockHeader parentHeader; + // AERE hybrid PQC: guards against re-importing once a block has been successfully imported this + // round, while still allowing a retry if an earlier import attempt was rejected (e.g. a post-fork + // Falcon quorum certificate that was not yet complete when the first attempt ran). + private boolean blockImported = false; + + // AERE ANCHOR V2: the ROUND-INDEPENDENT on-chain hash of the block this round is deciding. + // + // Why it is not simply block.getHash(): a proposal block is built with the COMMITTED-SEAL header + // functions, so its hash includes the round number and differs between rounds for one and the + // same block. The certificate carried by the NEXT block is looked up, and its message M is built, + // from the parent's on-chain hash, which excludes both the commit seals and the round. Keying the + // seal cache on anything round dependent would silently produce empty certificates at every round + // change. Replacing the round with 0 in the committed-seal encoding yields byte-for-byte the + // on-chain pre-image, so this is the same hash the parent header will carry once imported. + private Hash pqOnchainHash; + /** * Instantiates a new Qbft round. * @@ -159,8 +188,26 @@ public class QbftRound { final Optional blockAccessList; if (bestPreparedCertificate.isEmpty()) { LOG.debug("Sending proposal with new block. round={}", roundState.getRoundIdentifier()); - final BlockCreationResult blockCreationResult = - blockCreator.createBlock(headerTimestamp, this.parentHeader); + final BlockCreationResult blockCreationResult; + try { + blockCreationResult = blockCreator.createBlock(headerTimestamp, this.parentHeader); + } catch (final RuntimeException thrown) { + // MEASURED 2026-08-02: the refusal reaches here WRAPPED. AbstractBlockCreator.createBlock + // catches every exception from the extra-data calculator and rethrows it as + // IllegalStateException, so catching PqAnchorNotReadyException directly never fired and the + // escape aborted handleRoundChangePayload, skipping the multicastRoundChange that + // doRoundChange issues after it. Anything that is NOT a refusal is rethrown untouched. + final PqAnchorNotReadyException e = PqAnchorNotReadyException.findIn(thrown); + if (e == null) { + throw thrown; + } + // AERE ANCHOR V2: this node cannot back the block with a certificate that reaches the + // staged threshold, so it does NOT propose. See PqAnchorNotReadyException for why refusing + // is strictly better than proposing a header we have already computed to be invalid: in + // QBFT both end at the same round timer, so proposing anyway buys no liveness at all. + LOG.warn("AERE PQ-ANCHOR: not proposing. round={}. {}", getRoundIdentifier(), e.getMessage()); + return; + } blockToPublish = blockCreationResult.block(); blockAccessList = blockCreationResult.blockAccessList(); } else { @@ -197,20 +244,48 @@ public class QbftRound { final List> prepares) { final Proposal proposal; try { + // AERE PQ PROPOSAL (2026-08-30): the seal is computed once, here, and rides INSIDE the + // signed payload. With the gate closed - every node today - the seal is empty and the OLD + // overload is taken call for call, so a node that emits nothing new behaves identically to + // one from before this change. Same pattern as PREPARE and commit, deliberately. + final Optional proposalSeal = proposalSealFor(block); proposal = - messageFactory.createProposal( - getRoundIdentifier(), block, blockAccessList, roundChanges, prepares); + proposalSeal.isPresent() + ? messageFactory.createProposal( + getRoundIdentifier(), block, blockAccessList, roundChanges, prepares, + proposalSeal) + : messageFactory.createProposal( + getRoundIdentifier(), block, blockAccessList, roundChanges, prepares); } catch (final SecurityModuleException e) { LOG.warn("Failed to create a signed Proposal, waiting for next round.", e); return; } - transmitter.multicastProposal( - proposal.getRoundIdentifier(), - proposal.getSignedPayload().getPayload().getProposedBlock(), - proposal.getBlockAccessList(), - roundChanges, - prepares); + // THE SAME seal object goes on the wire. The transmitter re-creates the proposal, so without + // this the local copy is sealed and the wire copy is not - measured on the first network run + // (F84 scenario A): every peer refused height H round after round while this node's own log + // said it had emitted. And it must be the SAME object, not a second signing: Falcon signatures + // are randomised, so a re-signed wire copy would differ from the local one byte for byte. + // With the gate closed the OLD call is taken, call for call - the upstream tests assert the + // five-argument form, and a node that emits nothing new must go through the same calls. + final Optional wireSeal = + proposal.getSignedPayload().getPayload().getFalconSeal(); + if (wireSeal.isPresent()) { + transmitter.multicastProposal( + proposal.getRoundIdentifier(), + proposal.getSignedPayload().getPayload().getProposedBlock(), + proposal.getBlockAccessList(), + roundChanges, + prepares, + wireSeal); + } else { + transmitter.multicastProposal( + proposal.getRoundIdentifier(), + proposal.getSignedPayload().getPayload().getProposedBlock(), + proposal.getBlockAccessList(), + roundChanges, + prepares); + } if (updateStateWithProposedBlock(proposal)) { sendPrepare(block); } @@ -235,16 +310,70 @@ public class QbftRound { private void sendPrepare(final QbftBlock block) { LOG.debug("Sending prepare message. round={}", roundState.getRoundIdentifier()); try { + // AERE PQ PREPARE (2026-08-28), step 2: the seal is computed EXACTLY ONCE here and is + // handed to both the local copy and the one on the wire. Falcon signatures are randomised, + // so two signings of the same message give two different byte strings; if each copy signed + // its own, the same validator would produce two valid and DIFFERENT PREPAREs for the same + // round. The gate is closed on every node today, so this is empty until a decision. + final Optional falconSeal = prepareSealFor(block); + // WITH THE GATE CLOSED the OLD path is taken, call for call. This is not style: the upstream + // tests assert exactly the two-argument call, and more importantly a node that emits nothing + // new must behave identically to one from before this change - not merely write the same + // bytes, but go through the same calls. That way the binary can sit on the fleet with + // nothing changing until a decision is made. Same pattern as commit. final Prepare localPrepareMessage = - messageFactory.createPrepare(getRoundIdentifier(), block.getHash()); + falconSeal.isPresent() + ? messageFactory.createPrepare(getRoundIdentifier(), block.getHash(), falconSeal) + : messageFactory.createPrepare(getRoundIdentifier(), block.getHash()); peerIsPrepared(localPrepareMessage); - transmitter.multicastPrepare( - localPrepareMessage.getRoundIdentifier(), localPrepareMessage.getDigest()); + if (falconSeal.isPresent()) { + transmitter.multicastPrepare( + localPrepareMessage.getRoundIdentifier(), localPrepareMessage.getDigest(), falconSeal); + } else { + transmitter.multicastPrepare( + localPrepareMessage.getRoundIdentifier(), localPrepareMessage.getDigest()); + } } catch (final SecurityModuleException e) { LOG.warn("Failed to create a signed Prepare; {}", e.getMessage()); } } + /** + * This node's post-quantum seal for the PREPARE of the given block, or empty. + * + *

The signed message has ITS OWN DOMAIN and contains the ROUND - see PqAnchor.prepareMessage + * and the 2026-08-28 design note. Under the commit domain, a PREPARE seal given honestly could + * be pasted onto a forged COMMIT. + */ + private Optional prepareSealFor(final QbftBlock block) { + final long blockNumber = block.getHeader().getNumber(); + final Bytes32 message = + PqAnchor.prepareMessage( + PqAnchorProducer.config().chainId(), + blockNumber, + getRoundIdentifier().getRoundNumber(), + block.getHash().getBytes()); + return FalconSealSupport.instance().signPrepare(blockNumber, message); + } + + /** + * This node's post-quantum seal for its own PROPOSAL of the given block, or empty. + * + *

Its own domain again - see PqAnchor.proposalMessage. A proposal is an offer, not a vote: + * under the PREPARE or commit domain, a proposal seal given honestly could be pasted onto a + * forged vote and counted. + */ + private Optional proposalSealFor(final QbftBlock block) { + final long blockNumber = block.getHeader().getNumber(); + final Bytes32 message = + PqAnchor.proposalMessage( + PqAnchorProducer.config().chainId(), + blockNumber, + getRoundIdentifier().getRoundNumber(), + block.getHash().getBytes()); + return FalconSealSupport.instance().signProposal(blockNumber, message); + } + /** * Handle prepare message. * @@ -282,23 +411,39 @@ public class QbftRound { private boolean updateStateWithProposedBlock(final Proposal msg) { final boolean wasPrepared = roundState.isPrepared(); - final boolean wasCommitted = roundState.isCommitted(); final boolean blockAccepted = roundState.setProposedBlock(msg); if (blockAccepted) { final QbftBlock block = roundState.getProposedBlock().get(); + + // AERE hybrid PQC: compute the commit hash once, then produce BOTH the decisive ECDSA commit + // seal and the parallel Falcon-512 seal over the SAME commit hash. The Falcon seal is gossiped + // on the commit message so every validator's post-quantum seal reaches the block assembler. + final Hash commitHash; final SECPSignature commitSeal; try { - commitSeal = createCommitSeal(block); + commitHash = commitHashFor(block); + commitSeal = nodeKey.sign(Bytes32.wrap(commitHash.getBytes())); } catch (final SecurityModuleException e) { LOG.warn("Failed to construct commit seal; {}", e.getMessage()); return true; } + final Optional falconSeal = falconSealFor(block, commitHash); + // AERE HYBRID: the other schemes' extras, over the SAME message; empty on any node today. + final java.util.List extraSeals = + falconSeal.isPresent() ? extraSealsFor(block, commitHash) : java.util.List.of(); // There are times handling a proposed block is enough to enter prepared. if (wasPrepared != roundState.isPrepared()) { LOG.debug("Sending commit message. round={}", roundState.getRoundIdentifier()); - transmitter.multicastCommit(getRoundIdentifier(), block.getHash(), commitSeal); + if (!extraSeals.isEmpty()) { + transmitter.multicastCommit( + getRoundIdentifier(), block.getHash(), commitSeal, falconSeal, extraSeals); + } else if (falconSeal.isPresent()) { + transmitter.multicastCommit(getRoundIdentifier(), block.getHash(), commitSeal, falconSeal); + } else { + transmitter.multicastCommit(getRoundIdentifier(), block.getHash(), commitSeal); + } } // can automatically add _our_ commit message to the roundState @@ -306,18 +451,27 @@ public class QbftRound { // prepare try { final Commit localCommitMessage = - messageFactory.createCommit( - roundState.getRoundIdentifier(), msg.getBlock().getHash(), commitSeal); + falconSeal.isPresent() + ? messageFactory.createCommit( + roundState.getRoundIdentifier(), + msg.getBlock().getHash(), + commitSeal, + falconSeal, + extraSeals) + : messageFactory.createCommit( + roundState.getRoundIdentifier(), msg.getBlock().getHash(), commitSeal); roundState.addCommitMessage(localCommitMessage); } catch (final SecurityModuleException e) { LOG.warn("Failed to create signed Commit message; {}", e.getMessage()); return true; } + // AERE ANCHOR V2: remember every Falcon seal heard for THIS block, so the proposer of the + // NEXT block can carry a certificate over it. This is the local node's own seal. + pqCacheHeardSeals(block); + // It is possible sufficient commit seals are now available and the block should be imported - if (wasCommitted != roundState.isCommitted()) { - importBlockToChain(); - } + maybeImportBlock(); } return blockAccepted; @@ -330,7 +484,20 @@ public class QbftRound { LOG.debug("Sending commit message. round={}", roundState.getRoundIdentifier()); final QbftBlock block = roundState.getProposedBlock().get(); try { - transmitter.multicastCommit(getRoundIdentifier(), block.getHash(), createCommitSeal(block)); + final Hash commitHash = commitHashFor(block); + final SECPSignature commitSeal = nodeKey.sign(Bytes32.wrap(commitHash.getBytes())); + final Optional falconSeal = falconSealFor(block, commitHash); + // AERE HIBRID: aceleasi extrase si pe drumul tarziu, ca cele doua locuri sa nu divearga. + final java.util.List extraSeals = + falconSeal.isPresent() ? extraSealsFor(block, commitHash) : java.util.List.of(); + if (!extraSeals.isEmpty()) { + transmitter.multicastCommit( + getRoundIdentifier(), block.getHash(), commitSeal, falconSeal, extraSeals); + } else if (falconSeal.isPresent()) { + transmitter.multicastCommit(getRoundIdentifier(), block.getHash(), commitSeal, falconSeal); + } else { + transmitter.multicastCommit(getRoundIdentifier(), block.getHash(), commitSeal); + } // Note: the local-node's commit message was added to RoundState on block acceptance // and thus does not need to be done again here. } catch (final SecurityModuleException e) { @@ -340,20 +507,50 @@ public class QbftRound { } private void peerIsCommitted(final Commit msg) { - final boolean wasCommitted = roundState.isCommitted(); roundState.addCommitMessage(msg); - if (wasCommitted != roundState.isCommitted()) { + // AERE ANCHOR V2: top up the seal cache on EVERY commit, including commits that arrive after + // this node has already imported the block. Those late seals are exactly the ones that let a + // proposer reach the threshold K without waiting for its own round to be re-run. + roundState.getProposedBlock().ifPresent(this::pqCacheHeardSeals); + if (roundState.getProposedBlock().isEmpty()) { + pqCacheSealsOfCommitWithoutProposal(msg); + } + // AERE hybrid PQC: attempt import on every commit while not yet imported. In the common case + // this fires exactly once (on the quorum-th commit). Post-fork, if the first attempt lacked a + // complete Falcon quorum certificate (e.g. one of the quorum's validators had a faulty Falcon + // key), a later commit that carries a valid Falcon seal lets the honest quorum complete. + maybeImportBlock(); + } + + private void maybeImportBlock() { + if (roundState.isCommitted() && !blockImported) { importBlockToChain(); } } private void importBlockToChain() { + // AERE hybrid PQC: pass the gossiped Falcon seals collected from commit messages so the block + // assembler can embed the post-quantum certificate. When no Falcon seals were gossiped (Falcon + // disabled), fall back to the unchanged ECDSA-only sealing path. + // + // WHAT GETS EMBEDDED, corrected 2026-08-19 (finding D-235): NOT a per-block 2f+1 quorum. The + // assembler writes a certificate only at an anchor height, over the PARENT, and it needs at + // least K valid seals, K being the configured schedule (6 of 9 on chain 2800 today). Between + // anchor heights nothing is written. The older wording here said "a >= 2f+1 Falcon quorum + // certificate" and described the legacy per-block rule, which is retired at the anchor block. + final Collection falconSeals = roundState.getFalconSeals(); final QbftBlock blockToImport = - blockCreator.createSealedBlock( - roundState.getProposedBlock().get(), - roundState.getRoundIdentifier().getRoundNumber(), - roundState.getCommitSeals()); + falconSeals.isEmpty() + ? blockCreator.createSealedBlock( + roundState.getProposedBlock().get(), + roundState.getRoundIdentifier().getRoundNumber(), + roundState.getCommitSeals()) + : blockCreator.createSealedBlock( + roundState.getProposedBlock().get(), + roundState.getRoundIdentifier().getRoundNumber(), + roundState.getCommitSeals(), + falconSeals); final long blockNumber = blockToImport.getHeader().getNumber(); if (getRoundIdentifier().getRoundNumber() > 0) { @@ -373,19 +570,170 @@ public class QbftRound { final boolean result = blockImporter.importBlock(blockToImport, roundState.getProposedBlockAccessList()); if (!result) { + // Do NOT set blockImported: a post-fork block whose Falcon quorum certificate is not yet + // complete can be retried as more commits (with valid Falcon seals) arrive this round. LOG.error( "Failed to import proposed block to chain. block={} blockHeader={}", blockNumber, blockToImport.getHeader()); } else { + blockImported = true; notifyNewBlockListeners(blockToImport); } } - private SECPSignature createCommitSeal(final QbftBlock block) { + private Hash commitHashFor(final QbftBlock block) { final QbftBlock commitBlock = createCommitBlock(block); - final Hash commitHash = commitBlock.getHash(); - return nodeKey.sign(Bytes32.wrap(commitHash.getBytes())); + return commitBlock.getHash(); + } + + /** + * AERE ANCHOR V2: remember the Falcon seals heard for a block, keyed on its ROUND-INDEPENDENT + * on-chain hash, which is the key the producer of the next block looks under. Cheap and + * idempotent: the cache keeps the first seal seen per validator index. + */ + /** + * D-339 (2026-09-04): keep the seals of a Commit even when this node has NO proposal for the + * round, keyed on the digest the Commit itself names. + * + *

Why, measured on chain 2800 the same evening: the proposer of an anchor kept refusing with + * "this node holds 1 valid eligible Falcon seal ... 0 rejected" - it was not rejecting seals, it + * had never heard them. The path above caches only when the round holds the proposed block, so a + * node that missed the proposal of the parent's round (or imported that block by any route other + * than its own round) collected the Commits and threw their seals away. It then could not build + * the certificate of the next anchor, the round expired, and every 32nd height paid the + * round-change timeout. Eleven of twelve anchors measured before this fix. + * + *

Safety: only the seal is copied, never the message; the digest is the one the Commit names, + * so a losing round writes an entry under a hash that never becomes canonical and is simply never + * looked up; and the producer Falcon-verifies every cached seal against the anchored registry + * before carrying it, so a seal that lies about its index still cannot reach a header. + * + * @param msg the commit whose seals are kept + */ + private void pqCacheSealsOfCommitWithoutProposal(final Commit msg) { + final long height = roundState.getRoundIdentifier().getSequenceNumber(); + msg.getFalconSeal() + .ifPresent( + seal -> + PqSealCache.instance() + .record(height, msg.getDigest(), java.util.List.of(seal))); + final java.util.List extras = + msg.getExtraSeals(); + if (!extras.isEmpty()) { + PqSealCache.instance().recordExtras(height, msg.getDigest(), extras); + } + } + + private void pqCacheHeardSeals(final QbftBlock block) { + final Collection seals = roundState.getFalconSeals(); + if (seals.isEmpty()) { + return; + } + final Hash onchain = pqOnchainHashOf(block); + PqSealCache.instance().record(block.getHeader().getNumber(), onchain, seals); + // AERE ANCHOR V2: the extra scheme seals ride the same commits and go under the same key. + final java.util.List extras = + roundState.getExtraSeals(); + if (!extras.isEmpty()) { + PqSealCache.instance().recordExtras(block.getHeader().getNumber(), onchain, extras); + } + } + + /** + * AERE ANCHOR V2: the round-independent on-chain hash of the block this round is deciding, + * computed once per round. Replacing the round with 0 in the committed-seal encoding produces + * byte-for-byte the on-chain pre-image, because that encoding differs from the on-chain one only + * by writing the real round instead of a forced zero. + */ + private Hash pqOnchainHashOf(final QbftBlock block) { + if (pqOnchainHash == null) { + pqOnchainHash = blockInterface.replaceRoundForCommitBlock(block, 0).getHash(); + } + return pqOnchainHash; + } + + /** + * AERE HIBRID (2026-08-25): the non-Falcon seals of this node's hybrid certificate, over the + * SAME message the Falcon seal signs (the two are one certificate; two messages would be two + * certificates and the verifier could not bind them). Empty on every node that is not + * hybrid-configured, and below the emission gate: {@link HybridSealProducer} never throws and + * never emits half a certificate. + */ + private java.util.List extraSealsFor( + final QbftBlock block, final Hash commitHash) { + // ANCHOR PARENTS ONLY (2026-09-03, D-329): an SLH-DSA-128s signature costs seconds here; it is + // only ever used in the next anchor's certificate, which is built over the anchor's parent. So + // the extras are signed only when the block being committed is the parent of an anchor height. + // The same predicate gates the enforcement (PqCommitEnforcement.anchorParentByConfig). + final long number = block.getHeader().getNumber(); + if (!org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer.config() + .anchorAppliesAt(number + 1L)) { + return java.util.List.of(); + } + return hybridExtrasOrEmpty(number, pqSealMessageFor(block, commitHash)); + } + + /** + * D-334 (2026-09-03, mainnet 2800, 125 s of halted chain): the hybrid loader is lazy, so a + * registry the container could not read surfaced here, as an exception thrown INTO the QBFT + * state machine, at every anchor parent, on every armed node. Three such nodes plus one node + * restarting left five of nine. A broken hybrid configuration may cost this node its SLH-DSA + * contribution; it must never cost the node its vote. The loader still refuses to START on the + * same defect (QbftBesuControllerBuilder calls it at boot), so this catch is the second net. + * + * @param number the height being committed + * @param message the seal message + * @return the extras, or an empty list with an ERROR line when the hybrid support cannot sign + */ + static java.util.List hybridExtrasOrEmpty( + final long number, final Bytes32 message) { + try { + return org.hyperledger.besu.consensus.common.bft.HybridSealSupport.instance() + .producer() + .sealsFor(number, message); + } catch (final RuntimeException e) { + LOG.error( + "AERE HIBRID: extras NOT signed at height {} - {}. This node keeps voting but contributes no" + + " SLH-DSA seal until the hybrid configuration is repaired (D-334).", + number, + e.getMessage()); + return java.util.List.of(); + } + } + + /** The exact bytes a PQ seal over this block signs; shared by Falcon and the hybrid extras, + * so the two halves of a hybrid certificate can never drift onto different messages. */ + private Bytes32 pqSealMessageFor(final QbftBlock block, final Hash commitHash) { + // AERE D-311 (2026-09-02): the form is decided in ONE place, shared with the verifier + // (MessageValidator.SubsequentMessageValidator). A second copy of this rule drifted once and + // stopped a chain; see PqAnchorProducer.commitSealMessage. + return PqAnchorProducer.commitSealMessage( + block.getHeader().getNumber(), () -> pqOnchainHashOf(block).getBytes(), commitHash); + } + + private Optional falconSealFor(final QbftBlock block, final Hash commitHash) { + // FalconSealSupport.sign never throws (any fault is swallowed and logged), and returns empty + // when this node holds no Falcon signing key, so the ECDSA commit path is never affected. + // + // AERE FIX-OPRIRE-CONSENS (b): the block NUMBER is now a mandatory argument. Seal ATTACHMENT is + // gated on the activation height (and on registry coverage of the whole validator set) inside + // FalconSealSupport.sign, so merely holding a Falcon key no longer makes a node emit a + // Falcon-carrying Commit. There is deliberately no height-less sign() overload left: a call site + // physically cannot bypass the gate. + // + // AERE ANCHOR V2: WHAT IS SIGNED changes one block BEFORE the anchor activates, because the + // certificate carried by block H is made of seals over block H-1. From that height the message + // is M = keccak256(RLP["AERE-PQ-COMMIT-1", chainId, number, ON-CHAIN block hash]) instead of the + // ECDSA committed-seal hash. The committed-seal hash could not be used: it includes the ROUND, + // which is not in the block-hash pre-image, so a validator holding only the parent header could + // not rebuild it. Every node flips at the same height, since the height is a pure function of + // the same configured H; a node configured with a different H emits seals nobody can use, and + // the producer drops them on verification rather than carrying them into a header. + // AERE HIBRID (2026-08-25): mesajul se calculeaza acum intr-UN singur loc, pqSealMessageFor, + // impartit cu extrasele hibride; doua copii ale acestei logici ar fi divergat intr-o zi. + final long blockNumber = block.getHeader().getNumber(); + return FalconSealSupport.instance().sign(blockNumber, pqSealMessageFor(block, commitHash)); } private QbftBlock createCommitBlock(final QbftBlock block) { diff --git a/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/RoundState.java b/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/RoundState.java index 1c3078602..2c798c9e2 100644 --- a/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/RoundState.java +++ b/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/RoundState.java @@ -11,10 +11,17 @@ * specific language governing permissions and limitations under the License. * * SPDX-License-Identifier: Apache-2.0 + * + * Modifications Copyright contributors to the Aere Network. + * + * This file was modified by contributors to the Aere Network, as required by section 4(b) of the + * Apache License 2.0. The copyright header above is the upstream one and is left exactly as it was + * found, as section 4(c) requires. The change itself is in patches/0003-aere-pq-anchor.patch. */ package org.hyperledger.besu.consensus.qbft.core.statemachine; import org.hyperledger.besu.consensus.common.bft.ConsensusRoundIdentifier; +import org.hyperledger.besu.consensus.common.bft.FalconSeal; import org.hyperledger.besu.consensus.qbft.core.messagewrappers.Commit; import org.hyperledger.besu.consensus.qbft.core.messagewrappers.Prepare; import org.hyperledger.besu.consensus.qbft.core.messagewrappers.Proposal; @@ -27,6 +34,7 @@ import org.hyperledger.besu.ethereum.mainnet.block.access.list.BlockAccessList; import java.util.Collection; import java.util.LinkedHashMap; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.stream.Collectors; @@ -196,6 +204,35 @@ public class RoundState { .collect(Collectors.toList()); } + /** + * Gets the parallel post-quantum Falcon seals gossiped on the collected commit messages (AERE + * hybrid PQC). Only commits that carried a Falcon seal contribute; the result is the raw set of + * gossiped seals, to be verified and de-duplicated by the block creator / header validation rule + * when the quorum certificate is assembled and checked. + * + * @return the gossiped Falcon seals (possibly empty, never null) + */ + /** + * AERE ANCHOR V2: the extra (non-Falcon) scheme seals gossiped on the collected commit messages, + * raw and unverified, for the seal cache and the v2 certificate producer. + * + * @return the gossiped scheme seals (possibly empty, never null) + */ + public java.util.List getExtraSeals() { + return commitMessages.values().stream() + .flatMap(cp -> cp.getSignedPayload().getPayload().getExtraSeals().stream()) + .collect(Collectors.toList()); + } + + public Collection getFalconSeals() { + return commitMessages.values().stream() + .map(cp -> cp.getSignedPayload().getPayload().getFalconSeal()) + .filter(Objects::nonNull) + .filter(Optional::isPresent) + .map(Optional::get) + .collect(Collectors.toList()); + } + /** * Construct prepared certificate. * diff --git a/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/types/QbftBlockCreator.java b/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/types/QbftBlockCreator.java index afe4fb00e..c397cab98 100644 --- a/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/types/QbftBlockCreator.java +++ b/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/types/QbftBlockCreator.java @@ -11,13 +11,21 @@ * specific language governing permissions and limitations under the License. * * SPDX-License-Identifier: Apache-2.0 + * + * Modifications Copyright contributors to the Aere Network. + * + * This file was modified by contributors to the Aere Network, as required by section 4(b) of the + * Apache License 2.0. The copyright header above is the upstream one and is left exactly as it was + * found, as section 4(c) requires. The change itself is in patches/0003-aere-pq-anchor.patch. */ package org.hyperledger.besu.consensus.qbft.core.types; +import org.hyperledger.besu.consensus.common.bft.FalconSeal; import org.hyperledger.besu.crypto.SECPSignature; import org.hyperledger.besu.ethereum.mainnet.block.access.list.BlockAccessList; import java.util.Collection; +import java.util.Collections; import java.util.Optional; /** Responsible for creating a block. */ @@ -50,4 +58,33 @@ public interface QbftBlockCreator { */ QbftBlock createSealedBlock( final QbftBlock block, final int roundNumber, final Collection commitSeals); + + /** + * Create sealed block, additionally embedding a gossiped parallel Falcon-512 quorum certificate + * (AERE hybrid PQC). The default implementation ignores the Falcon seals and delegates to the + * ECDSA-only sealing, so existing implementations remain valid; the QBFT block creator adaptor + * overrides this to aggregate the Falcon seals into a per-block quorum certificate. + * + * @param block the block + * @param roundNumber the round number + * @param commitSeals the decisive ECDSA commit seals + * @param falconSeals the gossiped parallel Falcon-512 seals collected from commit messages + * @return the block + */ + default QbftBlock createSealedBlock( + final QbftBlock block, + final int roundNumber, + final Collection commitSeals, + final Collection falconSeals) { + return createSealedBlock(block, roundNumber, commitSeals); + } + + /** + * Convenience empty Falcon seal collection. + * + * @return an empty collection of Falcon seals + */ + static Collection noFalconSeals() { + return Collections.emptyList(); + } } diff --git a/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/CommitValidator.java b/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/CommitValidator.java index e211c6a8e..918607ad2 100644 --- a/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/CommitValidator.java +++ b/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/CommitValidator.java @@ -11,6 +11,14 @@ * specific language governing permissions and limitations under the License. * * SPDX-License-Identifier: Apache-2.0 + * + * Modifications Copyright contributors to the Aere Network. + * + * This file was modified by contributors to the Aere Network, as required by section 4(b) of the + * Apache License 2.0. The copyright header above is the upstream one and is left exactly as it was + * found, as section 4(c) requires. The change: an OPTIONAL, height-gated post-quantum enforcement + * hook (see PqCommitEnforcement). When no enforcement is supplied, behaviour is byte-for-byte the + * upstream behaviour; the existing constructor keeps that contract for every existing caller. */ package org.hyperledger.besu.consensus.qbft.core.validation; @@ -23,6 +31,7 @@ import org.hyperledger.besu.datatypes.Hash; import org.hyperledger.besu.ethereum.core.Util; import java.util.Collection; +import java.util.Optional; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -38,9 +47,23 @@ public class CommitValidator { private final ConsensusRoundIdentifier targetRound; private final Hash expectedDigest; private final Hash expectedCommitDigest; + // AERE D-311 (2026-09-02): the message the post-quantum seal must have signed. It is NOT always + // the committed-seal digest: once the anchor is armed the emitter signs the anchor form (see + // PqAnchorProducer.commitSealMessage). The upstream-shaped constructors keep the digest, so no + // existing caller changes behaviour; the production path (MessageValidator) passes the real one. + private final Hash expectedPqSealMessage; + // AERE full-PQ: optional height-gated enforcement. Null means "upstream behaviour", which is + // exactly what the pre-existing constructor supplies, so nothing changes for existing callers. + private final PqCommitEnforcement pqEnforcement; /** - * Instantiates a new Commit validator. + * Instantiates a new Commit validator, self-wiring the AERE post-quantum enforcement from the + * system configuration. + * + *

With {@code aere.pq.commitPq.forkBlock} absent -- every fleet node today, and every test + * JVM -- this is byte-for-byte the upstream behaviour. With it set, commits at or above that + * height only count with a valid post-quantum seal of their own author. A present but broken + * value refuses loudly here rather than silently disarming. * * @param validators the validators * @param targetRound the target round @@ -52,10 +75,93 @@ public class CommitValidator { final ConsensusRoundIdentifier targetRound, final Hash expectedDigest, final Hash expectedCommitDigest) { + this( + validators, + targetRound, + expectedDigest, + expectedCommitDigest, + expectedCommitDigest, + PqCommitEnforcement.fromSystemConfig()); + } + + /** + * AERE D-311: the production constructor. Self-wires enforcement from the system configuration + * and takes the exact message the PQ seal signs at this height, computed by the caller with + * {@code PqAnchorProducer.commitSealMessage}, the same helper the emitter uses. + * + * @param validators the validators + * @param targetRound the target round + * @param expectedDigest the expected digest + * @param expectedCommitDigest the expected commit digest (ECDSA committed seal) + * @param expectedPqSealMessage the message the post-quantum seal must have signed + */ + public CommitValidator( + final Collection

validators, + final ConsensusRoundIdentifier targetRound, + final Hash expectedDigest, + final Hash expectedCommitDigest, + final Hash expectedPqSealMessage) { + this( + validators, + targetRound, + expectedDigest, + expectedCommitDigest, + expectedPqSealMessage, + PqCommitEnforcement.fromSystemConfig()); + } + + /** + * Instantiates a new Commit validator with optional post-quantum enforcement. + * + *

AERE full-PQ: when {@code pqEnforcement} is non-null and armed at this round's height, a + * Commit only validates if it carries a post-quantum seal whose index is bound to the message + * author and whose signature verifies over the commit digest. A vote without valid PQ does not + * count toward quorum. + * + * @param validators the validators + * @param targetRound the target round + * @param expectedDigest the expected digest + * @param expectedCommitDigest the expected commit digest + * @param pqEnforcement the height-gated enforcement, or null for upstream behaviour + */ + public CommitValidator( + final Collection

validators, + final ConsensusRoundIdentifier targetRound, + final Hash expectedDigest, + final Hash expectedCommitDigest, + final PqCommitEnforcement pqEnforcement) { + this(validators, targetRound, expectedDigest, expectedCommitDigest, expectedCommitDigest, pqEnforcement); + } + + /** AERE D-311: the message the post-quantum seal must have signed; for the plumbing proof only. */ + @com.google.common.annotations.VisibleForTesting + Hash expectedPqSealMessageForTesting() { + return expectedPqSealMessage; + } + + /** + * AERE D-311: the fully explicit constructor (tests and the production constructor above). + * + * @param validators the validators + * @param targetRound the target round + * @param expectedDigest the expected digest + * @param expectedCommitDigest the expected commit digest (ECDSA committed seal) + * @param expectedPqSealMessage the message the post-quantum seal must have signed + * @param pqEnforcement the enforcement, or null for upstream behaviour + */ + public CommitValidator( + final Collection
validators, + final ConsensusRoundIdentifier targetRound, + final Hash expectedDigest, + final Hash expectedCommitDigest, + final Hash expectedPqSealMessage, + final PqCommitEnforcement pqEnforcement) { this.validators = validators; this.targetRound = targetRound; this.expectedDigest = expectedDigest; this.expectedCommitDigest = expectedCommitDigest; + this.expectedPqSealMessage = expectedPqSealMessage; + this.pqEnforcement = pqEnforcement; } /** @@ -112,6 +218,22 @@ public class CommitValidator { return false; } + // AERE full-PQ: above the arming height a commit vote only counts with a valid post-quantum + // seal bound to this very author. Below it (or with no enforcement supplied) nothing changes. + if (pqEnforcement != null) { + final Optional refusal = + pqEnforcement.refusal( + targetRound.getSequenceNumber(), + signedPayload.getAuthor(), + expectedPqSealMessage, + payload.getFalconSeal(), + payload.getExtraSeals()); + if (refusal.isPresent()) { + LOG.info("{}: {}", ERROR_PREFIX, refusal.get()); + return false; + } + } + return true; } } diff --git a/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/MessageValidator.java b/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/MessageValidator.java index d15d19e91..0f26c79a8 100644 --- a/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/MessageValidator.java +++ b/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/MessageValidator.java @@ -11,16 +11,24 @@ * specific language governing permissions and limitations under the License. * * SPDX-License-Identifier: Apache-2.0 + * + * Modifications Copyright contributors to the Aere Network. + * + * This file was modified by contributors to the Aere Network, as required by section 4(b) of the + * Apache License 2.0. The copyright header above is the upstream one and is left exactly as it was + * found, as section 4(c) requires. The change itself is in patches/0003-aere-pq-anchor.patch. */ package org.hyperledger.besu.consensus.qbft.core.validation; import org.hyperledger.besu.consensus.common.bft.ConsensusRoundIdentifier; +import org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer; import org.hyperledger.besu.consensus.qbft.core.messagewrappers.Commit; import org.hyperledger.besu.consensus.qbft.core.messagewrappers.Prepare; import org.hyperledger.besu.consensus.qbft.core.messagewrappers.Proposal; import org.hyperledger.besu.consensus.qbft.core.types.QbftBlock; import org.hyperledger.besu.consensus.qbft.core.types.QbftBlockInterface; import org.hyperledger.besu.datatypes.Address; +import org.hyperledger.besu.datatypes.Hash; import java.util.Collection; import java.util.Optional; @@ -55,9 +63,23 @@ public class MessageValidator { final QbftBlock commitBlock = blockInterface.replaceRoundForCommitBlock(proposalBlock, targetRound.getRoundNumber()); prepareValidator = new PrepareValidator(validators, targetRound, proposalBlock.getHash()); + // AERE D-311 (2026-09-02): the post-quantum seal on a commit signs the message that + // PqAnchorProducer.commitSealMessage names (the anchor form once the anchor is armed, the + // ECDSA committed-seal hash before), computed here exactly as the emitter computes it in + // QbftRound.pqSealMessageFor: same helper, same round-independent on-chain hash. + final Hash expectedPqSealMessage = + Hash.wrap( + PqAnchorProducer.commitSealMessage( + targetRound.getSequenceNumber(), + () -> blockInterface.replaceRoundForCommitBlock(proposalBlock, 0).getHash().getBytes(), + commitBlock.getHash())); commitValidator = new CommitValidator( - validators, targetRound, proposalBlock.getHash(), commitBlock.getHash()); + validators, + targetRound, + proposalBlock.getHash(), + commitBlock.getHash(), + expectedPqSealMessage); } /** @@ -76,6 +98,12 @@ public class MessageValidator { * @param msg the Commit payload msg * @return the boolean */ + /** AERE D-311: the commit validator this wiring built; for the plumbing proof only. */ + @com.google.common.annotations.VisibleForTesting + CommitValidator commitValidatorForTesting() { + return commitValidator; + } + public boolean validate(final Commit msg) { return commitValidator.validate(msg); } diff --git a/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/PqCommitEnforcement.java b/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/PqCommitEnforcement.java new file mode 100755 index 000000000..d1aa1be8e --- /dev/null +++ b/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/PqCommitEnforcement.java @@ -0,0 +1,369 @@ +/* + * AERE full-PQ consensus (TOP 3 list, item 1), step 1: the enforcement core. + * + * WHAT IT DECIDES. From the arming height upward, a Commit message counts toward the 2f+1 + * quorum ONLY if it carries the PQ seal (already transported in CommitPayload, live on the + * fleet) and the seal (a) exists, (b) has its index bound to the VERY author of the message + * through the height-indexed registry, (c) verifies over the commit digest. Without a valid + * PQ seal the vote does not count -- this puts post-quantum into the agreement itself, at the + * layer where the D-235 header rule could not live (headers between anchors carry no seals; + * the commit message can carry them all). + * + * HOW IT IS WIRED (updated the same night). CommitValidator calls it through the production + * constructor, which self-installs from fromSystemConfig(): absent property = null = + * upstream behaviour, DISARMED by default; a broken value = loud refusal, never a silent + * disarm. The core stays purely testable: the registry is injected (the PqSignerRegistry + * interface, which refuses by construction to answer without a height -- the D2 inheritance); + * the singleton enters only through the production factory, exactly like PqAnchorSealsRule. + */ +package org.hyperledger.besu.consensus.qbft.core.validation; + +import org.hyperledger.besu.consensus.common.bft.FalconSeal; +import org.hyperledger.besu.consensus.common.bft.HybridSealSupport; +import org.hyperledger.besu.consensus.common.bft.HybridSignerRegistry; +import org.hyperledger.besu.consensus.common.bft.PqSchemeSchedule; +import org.hyperledger.besu.consensus.common.bft.PqSignerRegistry; +import org.hyperledger.besu.consensus.common.bft.SchemeSeal; +import org.hyperledger.besu.consensus.common.bft.SealScheme; +import org.hyperledger.besu.consensus.common.bft.SealSchemes; +import org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer; +import org.hyperledger.besu.datatypes.Address; +import org.hyperledger.besu.datatypes.Hash; + +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.function.LongPredicate; + +import org.apache.tuweni.bytes.Bytes; + +/** Height-gated decision: does this Commit's PQ seal let it count toward quorum? */ +public final class PqCommitEnforcement { + + /** The disarmed height: no block ever reaches it, so nothing is enforced. */ + public static final long DISARMED = Long.MAX_VALUE; + + /** + * System property naming the first block height at which a Commit vote no longer counts without + * a valid post-quantum seal of its own author. Absent = disarmed, today's behaviour. Delivered + * per node through {@code BESU_OPTS}, like every other AERE consensus switch; there is NO + * consensus binding on the value, so the fleet must coordinate on it exactly as it does on the + * anchor heights. Env: {@code AERE_PQ_COMMITPQ_FORKBLOCK}. + */ + public static final String PROPERTY_FORK_BLOCK = "aere.pq.commitPq.forkBlock"; + + /** Environment fallback for {@link #PROPERTY_FORK_BLOCK}. */ + public static final String ENV_FORK_BLOCK = "AERE_PQ_COMMITPQ_FORKBLOCK"; + + /** + * The enforcement the production (4-arg) CommitValidator constructor wires in, read fresh from + * the system configuration on every call. + * + *

Absent configuration returns null, which CommitValidator treats as upstream behaviour -- + * the honest default. A PRESENT but unparseable value REFUSES loudly instead of disarming: + * the paid-for lesson of the anchor loader is that a mistyped value must never start a node + * silently disarmed ("a mistyped comma boots the node DISARMED"). The throw happens at + * validator construction, i.e. at the first round the node processes, which is as close to + * startup as this layer can get. + * + * @return the armed enforcement, or null when the property is not set anywhere + * @throws IllegalStateException AERE-PQC-COMMIT-CONF-01 when the value is present but not a + * non-negative decimal block height + */ + public static PqCommitEnforcement fromSystemConfig() { + String raw = System.getProperty(PROPERTY_FORK_BLOCK); + if (raw == null) { + raw = System.getenv(ENV_FORK_BLOCK); + } + if (raw == null) { + return null; + } + final long armedFrom; + try { + armedFrom = Long.parseLong(raw.trim()); + if (armedFrom < 0) { + throw new NumberFormatException("negative"); + } + } catch (final NumberFormatException e) { + throw new IllegalStateException( + "AERE-PQC-COMMIT-CONF-01: " + + PROPERTY_FORK_BLOCK + + " is set but not a non-negative block height: '" + + raw + + "'. A mistyped value must refuse, never silently disarm."); + } + // AERE HYBRID: when the node has the schedule+registry pair configured, enforcement + // receives it too, so from the hybrid step of the schedule onward a vote without ALL the + // required schemes does not count. Without the pair, this stays exactly the Falcon + // enforcement we had until now. + final HybridSealSupport hybrid = HybridSealSupport.instance(); + if (hybrid.schedule().isPresent()) { + return new PqCommitEnforcement( + armedFrom, + PqSignerRegistry.falconSealSupport(), + hybrid.schedule().get(), + hybrid.registry().orElseThrow()); + } + return new PqCommitEnforcement(armedFrom, PqSignerRegistry.falconSealSupport()); + } + + private final long armedFromBlock; + private final PqSignerRegistry registry; + // AERE HIBRID: null in Falcon-only mode, which is every node today. + private final PqSchemeSchedule schemeSchedule; + private final HybridSignerRegistry hybridRegistry; + private final LongPredicate extrasRequiredAt; + + /** + * @param armedFromBlock first block height (inclusive) at which enforcement applies; use + * {@link #DISARMED} for the today-behaviour + * @param registry the height-aware signer registry (injected, never a singleton) + */ + public PqCommitEnforcement(final long armedFromBlock, final PqSignerRegistry registry) { + this(armedFromBlock, registry, null, null); + } + + /** + * Enforcement that also demands the HYBRID schemes a schedule requires at each height. + * + *

AERE HIBRID (2026-08-25). Above the arming height a vote must carry, besides the Falcon + * seal checked by the Falcon-only path, a valid seal for EVERY other scheme the schedule names + * at that height. The point of a hybrid is that the two families fail independently, so a + * partially satisfied certificate is worth exactly as much as the weakest family present, which + * is why a missing scheme refuses rather than degrades. + * + *

NO REGISTRY-ALIGNMENT ASSUMPTION. The hybrid registry and the legacy Falcon registry are + * two files, and the paid-for lesson of D-191 is that a pair of files that must agree will one + * day not agree. So this code never assumes their index spaces line up: an extra seal must + * carry the SAME validator index as the Falcon seal on the same message, and that index must + * resolve, IN THE HYBRID REGISTRY, to the very author of the message. Both facts are checked, + * neither is assumed. + * + * @param armedFromBlock first block height (inclusive) at which enforcement applies + * @param registry the height-aware Falcon signer registry + * @param schemeSchedule which schemes are required at which height; null for Falcon-only + * @param hybridRegistry per-validator public keys per scheme; null for Falcon-only + */ + public PqCommitEnforcement( + final long armedFromBlock, + final PqSignerRegistry registry, + final PqSchemeSchedule schemeSchedule, + final HybridSignerRegistry hybridRegistry) { + this(armedFromBlock, registry, schemeSchedule, hybridRegistry, anchorParentByConfig()); + } + + /** + * ANCHOR PARENTS ONLY (2026-09-03, D-329). An SLH-DSA-SHA2-128s signature costs seconds in this + * runtime; demanded on EVERY commit it stretched the testnet from 0.5 s to 4 s per block. The + * extra seal is only ever USED in the certificate of the next anchor, which is built from the + * seals heard over the anchor's PARENT. So the extra scheme seals are required (and emitted, see + * QbftRound) only on commits over a block whose successor is an anchor height. Falcon stays on + * every commit, exactly as before. + * + * @return the predicate "the block at this height is the parent of an anchor", from the live + * anchor configuration + */ + public static LongPredicate anchorParentByConfig() { + return h -> PqAnchorProducer.config().anchorAppliesAt(h + 1L); + } + + /** + * Full constructor. + * + * @param extrasRequiredAt at which heights the extra (non-Falcon) scheme seals are demanded on a + * commit; production passes {@link #anchorParentByConfig()} + */ + public PqCommitEnforcement( + final long armedFromBlock, + final PqSignerRegistry registry, + final PqSchemeSchedule schemeSchedule, + final HybridSignerRegistry hybridRegistry, + final LongPredicate extrasRequiredAt) { + this.armedFromBlock = armedFromBlock; + this.registry = registry; + this.schemeSchedule = schemeSchedule; + this.hybridRegistry = hybridRegistry; + this.extrasRequiredAt = extrasRequiredAt; + if ((schemeSchedule == null) != (hybridRegistry == null)) { + // Half a hybrid configuration is the shape that starts a node believing it enforces + // something it does not. Refuse at construction, the same stance as every other AERE gate. + throw new IllegalStateException( + "AERE-PQC-COMMIT-CONF-03: the scheme schedule and the hybrid registry are a PAIR;" + + " configure both or neither."); + } + } + + /** Whether enforcement is active at {@code height}. */ + public boolean armedAt(final long height) { + return height >= armedFromBlock; + } + + /** + * Decide whether a Commit may count toward quorum. + * + * @param height the block height the commit targets (the round's sequence number) + * @param author the RECOVERED author of the signed Commit message (from its ECDSA signature) + * @param commitDigest the commit digest the PQ seal must have signed + * @param seal the optional PQ seal carried inside the payload + * @return empty when the commit counts; otherwise the refusal, with names and numbers + */ + public Optional refusal( + final long height, + final Address author, + final Hash commitDigest, + final Optional seal) { + return refusal(height, author, commitDigest, seal, List.of()); + } + + /** + * Decide whether a Commit may count toward quorum, hybrid certificate included. + * + * @param height the block height the commit targets + * @param author the RECOVERED author of the signed Commit message + * @param commitDigest the commit digest every seal must have signed + * @param seal the Falcon seal carried in its own slot + * @param extraSeals the non-Falcon scheme seals carried alongside it + * @return empty when the commit counts; otherwise the refusal, with names and numbers + */ + public Optional refusal( + final long height, + final Address author, + final Hash commitDigest, + final Optional seal, + final List extraSeals) { + final Optional falconVerdict = falconRefusal(height, author, commitDigest, seal); + if (falconVerdict.isPresent() + || !armedAt(height) + || schemeSchedule == null + || !extrasRequiredAt.test(height)) { + return falconVerdict; + } + return hybridRefusal(height, author, commitDigest, seal.orElseThrow(), extraSeals); + } + + /** + * Every scheme the schedule names at this height, other than Falcon, must be present and valid. + */ + private Optional hybridRefusal( + final long height, + final Address author, + final Hash commitDigest, + final FalconSeal falconSeal, + final List extraSeals) { + final Set required = schemeSchedule.schemesAt(height); + for (final String schemeId : required) { + if (SealSchemes.FALCON_512.id().equals(schemeId)) { + continue; // already decided by the Falcon path above + } + final Optional scheme = SealSchemes.byId(schemeId); + if (scheme.isEmpty()) { + return Optional.of( + "AERE HIBRID: the schedule requires scheme '" + schemeId + "' at height " + height + + " and this binary does not implement it - refusing rather than ignoring it"); + } + final byte wire = scheme.get().wireId(); + SchemeSeal found = null; + for (final SchemeSeal candidate : extraSeals) { + if (candidate.getSchemeWireId() == wire) { + found = candidate; + break; + } + } + if (found == null) { + return Optional.of( + "AERE HIBRID: commit at height " + height + " carries no " + schemeId + + " seal, which the schedule requires - the vote does not count"); + } + // One identity per message: the hybrid seal must speak for the same validator as the Falcon + // seal, and that index must be THIS author in the hybrid registry. Neither is assumed. + if (found.getValidatorIndex() != falconSeal.getValidatorIndex()) { + return Optional.of( + "AERE HIBRID: " + schemeId + " seal is index " + found.getValidatorIndex() + + " but the Falcon seal on the same commit is index " + + falconSeal.getValidatorIndex() + " - one commit, one signer"); + } + final Optional bound = hybridRegistry.address(found.getValidatorIndex()); + if (bound.isEmpty() + || !Address.wrap(Bytes.wrap(bound.get())).equals(author)) { + return Optional.of( + "AERE HIBRID: index " + found.getValidatorIndex() + + " is not bound to the commit author " + author + " in the hybrid registry"); + } + final Optional publicKey = + hybridRegistry.publicKey(found.getValidatorIndex(), schemeId); + if (publicKey.isEmpty()) { + return Optional.of( + "AERE HIBRID: the hybrid registry holds no " + schemeId + " key for index " + + found.getValidatorIndex()); + } + final boolean valid; + try { + valid = + scheme + .get() + .verifyRaw( + publicKey.get(), + commitDigest.getBytes().toArray(), + found.getSignature().toArray()); + } catch (final RuntimeException e) { + return Optional.of( + "AERE HIBRID: " + schemeId + " verification threw at height " + height + ": " + + e.getMessage()); + } + if (!valid) { + return Optional.of( + "AERE HIBRID: the " + schemeId + " seal of index " + found.getValidatorIndex() + + " does NOT verify over the commit digest at height " + height + + " - the vote does not count"); + } + } + return Optional.empty(); + } + + private Optional falconRefusal( + final long height, + final Address author, + final Hash commitDigest, + final Optional seal) { + if (!armedAt(height)) { + return Optional.empty(); + } + if (seal == null || seal.isEmpty()) { + return Optional.of( + "AERE FULL-PQ: commit at height " + height + " carries NO post-quantum seal and " + + "enforcement is armed from " + armedFromBlock + " - the vote does not count"); + } + final FalconSeal fs = seal.get(); + final Address bound; + try { + bound = registry.addressForIndexAtOwnHead(height, fs.getValidatorIndex()); + } catch (final RuntimeException e) { + return Optional.of( + "AERE FULL-PQ: registry refused index " + fs.getValidatorIndex() + " at height " + + height + ": " + e.getMessage()); + } + if (bound == null || !bound.equals(author)) { + return Optional.of( + "AERE FULL-PQ: seal index " + fs.getValidatorIndex() + " is bound to " + + bound + " but the commit was authored by " + author + + " - a seal cannot vouch for someone else's vote"); + } + final boolean valid; + try { + valid = + registry.verifyAtOwnHead( + height, fs.getValidatorIndex(), commitDigest.getBytes(), fs.getSignature()); + } catch (final RuntimeException e) { + return Optional.of( + "AERE FULL-PQ: verification threw for index " + fs.getValidatorIndex() + " at height " + + height + ": " + e.getMessage()); + } + if (!valid) { + return Optional.of( + "AERE FULL-PQ: post-quantum seal of index " + fs.getValidatorIndex() + + " does NOT verify over the commit digest at height " + height + + " - the vote does not count"); + } + return Optional.empty(); + } +} diff --git a/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/PqPrepareEnforcement.java b/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/PqPrepareEnforcement.java new file mode 100755 index 000000000..c95fdf691 --- /dev/null +++ b/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/PqPrepareEnforcement.java @@ -0,0 +1,206 @@ +/* + * Copyright contributors to Besu. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.qbft.core.validation; + +import org.hyperledger.besu.consensus.common.bft.FalconSeal; +import org.hyperledger.besu.consensus.common.bft.PqAnchor; +import org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer; +import org.hyperledger.besu.consensus.common.bft.PqSignerRegistry; +import org.hyperledger.besu.datatypes.Address; +import org.hyperledger.besu.datatypes.Hash; + +import java.util.Optional; + +import org.apache.tuweni.bytes.Bytes32; + +/** + * POST-QUANTUM ENFORCEMENT ON PREPARE. Step 4 of the design note + * PREPARE-SI-ROUNDCHANGE-SUB-PQ-PROIECTARE-2026-08-28. + * + *

From the armed height onwards, a PREPARE does not count without a valid post-quantum seal from + * its OWN author. The structure copies {@link PqCommitEnforcement} line for line on purpose: a + * second rendering of the same idea, written differently, diverges from the first one eventually. + * + *

WHY THIS IS A DIFFERENT LAYER FROM COMMIT, AND MORE DANGEROUS. Measured 2026-08-28 + * (finding D-277): commit enforcement can be bypassed by a single unarmed proposer. It gathers the + * commits, forms the block, and the others import it, because block import validates the HEADER, + * not the votes. PREPARE does not work that way: an armed node that refuses unsealed PREPAREs never + * reaches the "prepared" state, so it never sends COMMIT at all, and the unarmed node alone is not + * a quorum. So PREPARE enforcement is STRICTLY STRONGER - and that is exactly why it no longer has + * the safety net commit had during an activation. Arm it only after coverage has been measured. + * + *

WHAT THE SEAL SIGNS, AND WHY NOT THE SAME THING AS COMMIT. Its own domain, + * {@code AERE-PQ-PREPARE-1}, over (chainId, height, ROUND, digest). Under the commit domain, a + * PREPARE seal produced HONESTLY could be pasted onto a forged COMMIT and the enforcement there + * would accept it. The round is part of the message too: two PREPAREs for the same block in + * different rounds are two different assertions, and a seal from a failed round must not justify + * another one. + * + *

DISARMED BY DEFAULT. Without the property, {@link #fromSystemConfig()} returns null and + * the validator behaves exactly as upstream. A value that is PRESENT but unreadable REFUSES loudly: + * a node that boots disarmed because of a mistyped character looks exactly like a correctly + * configured one, right up to the day it matters. + */ +public final class PqPrepareEnforcement { + + /** The height nothing ever reaches: disarmed. */ + public static final long DISARMED = Long.MAX_VALUE; + + /** The property that arms PREPARE enforcement. */ + public static final String PROPERTY_FORK_BLOCK = "aere.pq.preparePq.forkBlock"; + + /** The equivalent environment variable. */ + public static final String ENV_FORK_BLOCK = "AERE_PQ_PREPAREPQ_FORKBLOCK"; + + private final long armedFromBlock; + private final PqSignerRegistry registry; + private final long chainId; + + /** + * @param armedFromBlock first height (inclusive) at which enforcement applies; {@link #DISARMED} + * for today's behaviour + * @param registry the signer registry, injected, never a singleton + * @param chainId the chain that goes into the signed message + */ + public PqPrepareEnforcement( + final long armedFromBlock, final PqSignerRegistry registry, final long chainId) { + this.armedFromBlock = armedFromBlock; + this.registry = registry; + this.chainId = chainId; + } + + /** + * The same enforcement, with the chain id taken from the anchor configuration. + * + *

THE CHAIN ID IS AN ARGUMENT, NOT A GLOBAL, and its own test caught that: the first version + * read it from {@code PqAnchorProducer.config()} in the middle of a consensus decision, so the + * test signed over 2800 while the enforcement verified over whatever the process configuration + * happened to be. A consensus decision that depends on global state cannot be tested honestly, + * and cannot be read either. The factories below fetch the value once, at construction, where it + * is visible. + * + * @param armedFromBlock first height at which enforcement applies + * @param registry the signer registry + */ + public PqPrepareEnforcement(final long armedFromBlock, final PqSignerRegistry registry) { + this(armedFromBlock, registry, PqAnchorProducer.config().chainId()); + } + + /** + * The configured enforcement, read FRESH on every call. + * + * @return the armed enforcement, or null when the property is set nowhere + * @throws IllegalStateException AERE-PQC-PREPARE-ENF-01 when the value is present but is not a + * non-negative decimal height + */ + public static PqPrepareEnforcement fromSystemConfig() { + String raw = System.getProperty(PROPERTY_FORK_BLOCK); + if (raw == null) { + raw = System.getenv(ENV_FORK_BLOCK); + } + if (raw == null || raw.isBlank()) { + return null; + } + final long armedFrom; + try { + armedFrom = Long.parseLong(raw.trim()); + if (armedFrom < 0) { + throw new NumberFormatException("negative"); + } + } catch (final NumberFormatException e) { + throw new IllegalStateException( + "AERE-PQC-PREPARE-ENF-01: " + + PROPERTY_FORK_BLOCK + + " is set but not a non-negative block height: '" + + raw + + "'. A mistyped value must refuse, never silently disarm."); + } + return new PqPrepareEnforcement(armedFrom, PqSignerRegistry.falconSealSupport()); + } + + /** Whether enforcement is active at this height. */ + public boolean armedAt(final long height) { + return height >= armedFromBlock; + } + + /** + * Decides whether a PREPARE may count. + * + * @param height the height the PREPARE targets (the round's sequence number) + * @param round the PREPARE's round; it is part of the signed message + * @param author the RECOVERED author of the signed message (from its ECDSA signature) + * @param digest the digest of the block the PREPARE speaks about + * @param seal the post-quantum seal carried by the payload, if any + * @return empty when the PREPARE counts; otherwise the refusal, with names and numbers + */ + public Optional refusal( + final long height, + final int round, + final Address author, + final Hash digest, + final Optional seal) { + if (!armedAt(height)) { + return Optional.empty(); + } + if (seal == null || seal.isEmpty()) { + return Optional.of( + "AERE FULL-PQ: prepare at height " + height + " round " + round + + " carries NO post-quantum seal and enforcement is armed from " + armedFromBlock + + " - the vote does not count"); + } + final FalconSeal fs = seal.get(); + final Address bound; + try { + bound = registry.addressForIndexAtOwnHead(height, fs.getValidatorIndex()); + } catch (final RuntimeException e) { + return Optional.of( + "AERE FULL-PQ: registry refused index " + fs.getValidatorIndex() + " at height " + + height + ": " + e.getMessage()); + } + if (bound == null || !bound.equals(author)) { + return Optional.of( + "AERE FULL-PQ: prepare seal index " + fs.getValidatorIndex() + " is bound to " + bound + + " but the prepare was authored by " + author + + " - a seal cannot vouch for someone else's vote"); + } + + final Bytes32 message; + try { + message = PqAnchor.prepareMessage(chainId, height, round, digest.getBytes()); + } catch (final RuntimeException e) { + // A message we cannot build means we cannot judge, and "cannot judge" must never be a pass: + // that would be exactly the silent disarming this file exists to refuse. + return Optional.of( + "AERE FULL-PQ: could not build the prepare message at height " + height + " round " + + round + ": " + e.getMessage()); + } + + final boolean valid; + try { + valid = registry.verifyAtOwnHead(height, fs.getValidatorIndex(), message, fs.getSignature()); + } catch (final RuntimeException e) { + return Optional.of( + "AERE FULL-PQ: verification threw for index " + fs.getValidatorIndex() + " at height " + + height + ": " + e.getMessage()); + } + if (!valid) { + return Optional.of( + "AERE FULL-PQ: post-quantum seal of index " + fs.getValidatorIndex() + + " does NOT verify over the prepare message at height " + height + " round " + round + + " - the vote does not count"); + } + return Optional.empty(); + } +} diff --git a/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/PqProposalEnforcement.java b/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/PqProposalEnforcement.java new file mode 100755 index 000000000..ea8817cd6 --- /dev/null +++ b/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/PqProposalEnforcement.java @@ -0,0 +1,202 @@ +/* + * Copyright contributors to Besu. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.qbft.core.validation; + +import org.hyperledger.besu.consensus.common.bft.FalconSeal; +import org.hyperledger.besu.consensus.common.bft.PqAnchor; +import org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer; +import org.hyperledger.besu.consensus.common.bft.PqSignerRegistry; +import org.hyperledger.besu.datatypes.Address; +import org.hyperledger.besu.datatypes.Hash; + +import java.util.Optional; + +import org.apache.tuweni.bytes.Bytes32; + +/** + * POST-QUANTUM ENFORCEMENT ON THE PROPOSAL. The hot-path step after PREPARE (AERE PQ, 2026-08-30). + * + *

From the armed height onwards, a PROPOSAL is not accepted without a valid post-quantum seal + * from its OWN proposer. The structure copies {@link PqProposalEnforcement} line for line on + * purpose: a second rendering of the same idea, written differently, diverges eventually. + * + *

WHAT THIS LAYER BUYS, said precisely. A proposal is an OFFER, not a vote: the design + * note of 2026-08-28 measures that "prepared" needs a full quorum of PREPAREs, proposer included, + * so an adversary who breaks ECDSA and forges proposals still cannot finalize anything while the + * PREPARE layer is armed. What forged proposals CAN do is start rounds and waste them - steering + * which honest proposals get considered and degrading liveness. This layer closes that: a proposal + * whose proposer cannot produce a Falcon seal does not even open a round on an armed node. + * + *

WHAT THE SEAL SIGNS. Its own domain, {@code AERE-PQ-PROPOSAL-1}, over (chainId, + * height, ROUND, digest). Under the PREPARE or commit domain, a proposal seal given honestly could + * be pasted onto a forged vote and counted; one changed string in the preimage makes the + * signatures non-transferable in both directions. + * + *

DISARMED BY DEFAULT. Without the property, {@link #fromSystemConfig()} returns null and + * the validator behaves exactly as upstream. A value that is PRESENT but unreadable REFUSES loudly: + * a node that boots disarmed because of a mistyped character looks exactly like a correctly + * configured one, right up to the day it matters. + */ +public final class PqProposalEnforcement { + + /** The height nothing ever reaches: disarmed. */ + public static final long DISARMED = Long.MAX_VALUE; + + /** The property that arms PROPOSAL enforcement. */ + public static final String PROPERTY_FORK_BLOCK = "aere.pq.proposalPq.forkBlock"; + + /** The equivalent environment variable. */ + public static final String ENV_FORK_BLOCK = "AERE_PQ_PROPOSALPQ_FORKBLOCK"; + + private final long armedFromBlock; + private final PqSignerRegistry registry; + private final long chainId; + + /** + * @param armedFromBlock first height (inclusive) at which enforcement applies; {@link #DISARMED} + * for today's behaviour + * @param registry the signer registry, injected, never a singleton + * @param chainId the chain that goes into the signed message + */ + public PqProposalEnforcement( + final long armedFromBlock, final PqSignerRegistry registry, final long chainId) { + this.armedFromBlock = armedFromBlock; + this.registry = registry; + this.chainId = chainId; + } + + /** + * The same enforcement, with the chain id taken from the anchor configuration. + * + *

THE CHAIN ID IS AN ARGUMENT, NOT A GLOBAL, and its own test caught that: the first version + * read it from {@code PqAnchorProducer.config()} in the middle of a consensus decision, so the + * test signed over 2800 while the enforcement verified over whatever the process configuration + * happened to be. A consensus decision that depends on global state cannot be tested honestly, + * and cannot be read either. The factories below fetch the value once, at construction, where it + * is visible. + * + * @param armedFromBlock first height at which enforcement applies + * @param registry the signer registry + */ + public PqProposalEnforcement(final long armedFromBlock, final PqSignerRegistry registry) { + this(armedFromBlock, registry, PqAnchorProducer.config().chainId()); + } + + /** + * The configured enforcement, read FRESH on every call. + * + * @return the armed enforcement, or null when the property is set nowhere + * @throws IllegalStateException AERE-PQC-PROPOSAL-ENF-01 when the value is present but is not a + * non-negative decimal height + */ + public static PqProposalEnforcement fromSystemConfig() { + String raw = System.getProperty(PROPERTY_FORK_BLOCK); + if (raw == null) { + raw = System.getenv(ENV_FORK_BLOCK); + } + if (raw == null || raw.isBlank()) { + return null; + } + final long armedFrom; + try { + armedFrom = Long.parseLong(raw.trim()); + if (armedFrom < 0) { + throw new NumberFormatException("negative"); + } + } catch (final NumberFormatException e) { + throw new IllegalStateException( + "AERE-PQC-PROPOSAL-ENF-01: " + + PROPERTY_FORK_BLOCK + + " is set but not a non-negative block height: '" + + raw + + "'. A mistyped value must refuse, never silently disarm."); + } + return new PqProposalEnforcement(armedFrom, PqSignerRegistry.falconSealSupport()); + } + + /** Whether enforcement is active at this height. */ + public boolean armedAt(final long height) { + return height >= armedFromBlock; + } + + /** + * Decides whether a PROPOSAL may be accepted. + * + * @param height the height the PROPOSAL targets (the round's sequence number) + * @param round the PROPOSAL's round; it is part of the signed message + * @param author the RECOVERED author of the signed message (from its ECDSA signature) + * @param digest the digest of the proposed block + * @param seal the post-quantum seal carried by the payload, if any + * @return empty when the PROPOSAL is acceptable; otherwise the refusal, with names and numbers + */ + public Optional refusal( + final long height, + final int round, + final Address author, + final Hash digest, + final Optional seal) { + if (!armedAt(height)) { + return Optional.empty(); + } + if (seal == null || seal.isEmpty()) { + return Optional.of( + "AERE FULL-PQ: proposal at height " + height + " round " + round + + " carries NO post-quantum seal and enforcement is armed from " + armedFromBlock + + " - the proposal is refused"); + } + final FalconSeal fs = seal.get(); + final Address bound; + try { + bound = registry.addressForIndexAtOwnHead(height, fs.getValidatorIndex()); + } catch (final RuntimeException e) { + return Optional.of( + "AERE FULL-PQ: registry refused index " + fs.getValidatorIndex() + " at height " + + height + ": " + e.getMessage()); + } + if (bound == null || !bound.equals(author)) { + return Optional.of( + "AERE FULL-PQ: proposal seal index " + fs.getValidatorIndex() + " is bound to " + bound + + " but the proposal was authored by " + author + + " - a seal cannot vouch for someone else's proposal"); + } + + final Bytes32 message; + try { + message = PqAnchor.proposalMessage(chainId, height, round, digest.getBytes()); + } catch (final RuntimeException e) { + // A message we cannot build means we cannot judge, and "cannot judge" must never be a pass: + // that would be exactly the silent disarming this file exists to refuse. + return Optional.of( + "AERE FULL-PQ: could not build the proposal message at height " + height + " round " + + round + ": " + e.getMessage()); + } + + final boolean valid; + try { + valid = registry.verifyAtOwnHead(height, fs.getValidatorIndex(), message, fs.getSignature()); + } catch (final RuntimeException e) { + return Optional.of( + "AERE FULL-PQ: verification threw for index " + fs.getValidatorIndex() + " at height " + + height + ": " + e.getMessage()); + } + if (!valid) { + return Optional.of( + "AERE FULL-PQ: post-quantum seal of index " + fs.getValidatorIndex() + + " does NOT verify over the proposal message at height " + height + " round " + round + + " - the proposal is refused"); + } + return Optional.empty(); + } +} diff --git a/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/PqRoundChangeEnforcement.java b/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/PqRoundChangeEnforcement.java new file mode 100755 index 000000000..1b952f619 --- /dev/null +++ b/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/PqRoundChangeEnforcement.java @@ -0,0 +1,209 @@ +/* + * Copyright contributors to Besu. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.qbft.core.validation; + +import org.hyperledger.besu.consensus.common.bft.FalconSeal; +import org.hyperledger.besu.consensus.common.bft.PqAnchor; +import org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer; +import org.hyperledger.besu.consensus.common.bft.PqSignerRegistry; +import org.hyperledger.besu.consensus.qbft.core.payload.PreparedRoundMetadata; +import org.hyperledger.besu.datatypes.Address; + +import java.util.Optional; + +import org.apache.tuweni.bytes.Bytes32; + +/** + * POST-QUANTUM ENFORCEMENT ON THE ROUND-CHANGE. The last hot-path message (AERE PQ, 2026-08-31). + * + *

From the armed height onwards, a ROUND-CHANGE is not accepted without a valid post-quantum + * seal from its OWN author. The structure copies {@link PqProposalEnforcement} line for line on + * purpose: a second rendering of the same idea, written differently, diverges eventually. + * + *

WHAT THIS LAYER BUYS, said precisely. With PREPARE armed, an adversary who breaks + * ECDSA cannot finalize; with PROPOSAL armed, it cannot open rounds. What it can still do is FORCE + * round changes: a forged quorum of round-changes moves every honest node to a round of the + * adversary's choosing, over and over - the remaining liveness lever, and, because a round-change + * may claim a prepared block, a lever over WHICH block gets re-proposed. This layer closes it: a + * round-change whose author cannot produce a Falcon seal does not count towards a round change on + * an armed node. + * + *

WHAT THE SEAL SIGNS. Its own domain, {@code AERE-PQ-ROUNDCHANGE-1}, over (chainId, + * height, targetRound, prepared metadata). The metadata is in the preimage so a seal from a bare + * round-change cannot be pasted onto one that claims a prepared block, and vice versa. + * + *

DISARMED BY DEFAULT. Without the property, {@link #fromSystemConfig()} returns null and + * the validator behaves exactly as upstream. A value that is PRESENT but unreadable REFUSES loudly: + * a node that boots disarmed because of a mistyped character looks exactly like a correctly + * configured one, right up to the day it matters. + */ +public final class PqRoundChangeEnforcement { + + /** The height nothing ever reaches: disarmed. */ + public static final long DISARMED = Long.MAX_VALUE; + + /** The property that arms ROUND-CHANGE enforcement. */ + public static final String PROPERTY_FORK_BLOCK = "aere.pq.roundChangePq.forkBlock"; + + /** The equivalent environment variable. */ + public static final String ENV_FORK_BLOCK = "AERE_PQ_ROUNDCHANGEPQ_FORKBLOCK"; + + private final long armedFromBlock; + private final PqSignerRegistry registry; + private final long chainId; + + /** + * @param armedFromBlock first height (inclusive) at which enforcement applies; {@link #DISARMED} + * for today's behaviour + * @param registry the signer registry, injected, never a singleton + * @param chainId the chain that goes into the signed message + */ + public PqRoundChangeEnforcement( + final long armedFromBlock, final PqSignerRegistry registry, final long chainId) { + this.armedFromBlock = armedFromBlock; + this.registry = registry; + this.chainId = chainId; + } + + /** + * The same enforcement, with the chain id taken from the anchor configuration. + * + *

THE CHAIN ID IS AN ARGUMENT, NOT A GLOBAL - the PROPOSAL enforcement's own test caught the + * version that read it mid-decision. The factories fetch the value once, at construction, where + * it is visible. + * + * @param armedFromBlock first height at which enforcement applies + * @param registry the signer registry + */ + public PqRoundChangeEnforcement(final long armedFromBlock, final PqSignerRegistry registry) { + this(armedFromBlock, registry, PqAnchorProducer.config().chainId()); + } + + /** + * The configured enforcement, read FRESH on every call. + * + * @return the armed enforcement, or null when the property is set nowhere + * @throws IllegalStateException AERE-PQC-ROUNDCHANGE-ENF-01 when the value is present but is not + * a non-negative decimal height + */ + public static PqRoundChangeEnforcement fromSystemConfig() { + String raw = System.getProperty(PROPERTY_FORK_BLOCK); + if (raw == null) { + raw = System.getenv(ENV_FORK_BLOCK); + } + if (raw == null || raw.isBlank()) { + return null; + } + final long armedFrom; + try { + armedFrom = Long.parseLong(raw.trim()); + if (armedFrom < 0) { + throw new NumberFormatException("negative"); + } + } catch (final NumberFormatException e) { + throw new IllegalStateException( + "AERE-PQC-ROUNDCHANGE-ENF-01: " + + PROPERTY_FORK_BLOCK + + " is set but not a non-negative block height: '" + + raw + + "'. A mistyped value must refuse, never silently disarm."); + } + return new PqRoundChangeEnforcement(armedFrom, PqSignerRegistry.falconSealSupport()); + } + + /** Whether enforcement is active at this height. */ + public boolean armedAt(final long height) { + return height >= armedFromBlock; + } + + /** + * Decides whether a ROUND-CHANGE may be accepted. + * + * @param height the height the round-change targets (the round's sequence number) + * @param targetRound the round the author wants to move to; it is part of the signed message + * @param author the RECOVERED author of the signed message (from its ECDSA signature) + * @param preparedRoundMetadata the prepared-round claim carried by the payload, if any; it is + * part of the signed message + * @param seal the post-quantum seal carried by the payload, if any + * @return empty when the round-change is acceptable; otherwise the refusal, with names and + * numbers + */ + public Optional refusal( + final long height, + final int targetRound, + final Address author, + final Optional preparedRoundMetadata, + final Optional seal) { + if (!armedAt(height)) { + return Optional.empty(); + } + if (seal == null || seal.isEmpty()) { + return Optional.of( + "AERE FULL-PQ: round-change at height " + height + " towards round " + targetRound + + " carries NO post-quantum seal and enforcement is armed from " + armedFromBlock + + " - the round-change is refused"); + } + final FalconSeal fs = seal.get(); + final Address bound; + try { + bound = registry.addressForIndexAtOwnHead(height, fs.getValidatorIndex()); + } catch (final RuntimeException e) { + return Optional.of( + "AERE FULL-PQ: registry refused index " + fs.getValidatorIndex() + " at height " + + height + ": " + e.getMessage()); + } + if (bound == null || !bound.equals(author)) { + return Optional.of( + "AERE FULL-PQ: round-change seal index " + fs.getValidatorIndex() + " is bound to " + + bound + " but the round-change was authored by " + author + + " - a seal cannot vouch for someone else's round-change"); + } + + final Bytes32 message; + try { + message = + preparedRoundMetadata.isPresent() + ? PqAnchor.roundChangeMessage( + chainId, + height, + targetRound, + preparedRoundMetadata.get().getPreparedRound(), + preparedRoundMetadata.get().getPreparedBlockHash().getBytes()) + : PqAnchor.roundChangeMessage(chainId, height, targetRound); + } catch (final RuntimeException e) { + // A message we cannot build means we cannot judge, and "cannot judge" must never be a pass: + // that would be exactly the silent disarming this file exists to refuse. + return Optional.of( + "AERE FULL-PQ: could not build the round-change message at height " + height + + " towards round " + targetRound + ": " + e.getMessage()); + } + + final boolean valid; + try { + valid = registry.verifyAtOwnHead(height, fs.getValidatorIndex(), message, fs.getSignature()); + } catch (final RuntimeException e) { + return Optional.of( + "AERE FULL-PQ: verification threw for index " + fs.getValidatorIndex() + " at height " + + height + ": " + e.getMessage()); + } + if (!valid) { + return Optional.of( + "AERE FULL-PQ: post-quantum seal of index " + fs.getValidatorIndex() + + " does NOT verify over the round-change message at height " + height + + " towards round " + targetRound + " - the round-change is refused"); + } + return Optional.empty(); + } +} diff --git a/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/PrepareValidator.java b/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/PrepareValidator.java index 61bde6a80..bfe651ad6 100644 --- a/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/PrepareValidator.java +++ b/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/PrepareValidator.java @@ -11,6 +11,12 @@ * specific language governing permissions and limitations under the License. * * SPDX-License-Identifier: Apache-2.0 + * + * Modifications Copyright contributors to the Aere Network. + * + * This file was modified by contributors to the Aere Network, as required by section 4(b) of the + * Apache License 2.0. The copyright header above is the upstream one and is left exactly as it was + * found, as section 4(c) requires. The change itself is in patches/0003-aere-pq-anchor.patch. */ package org.hyperledger.besu.consensus.qbft.core.validation; @@ -22,11 +28,20 @@ import org.hyperledger.besu.datatypes.Address; import org.hyperledger.besu.datatypes.Hash; import java.util.Collection; +import java.util.Optional; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -/** The Prepare validator. */ +/** + * The Prepare validator. + * + *

AERE (2026-08-29), step 4 of PREPARE-SI-ROUNDCHANGE-SUB-PQ-PROIECTARE-2026-08-28: an OPTIONAL + * post-quantum enforcement hook, gated on height (see {@link PqPrepareEnforcement}). When no + * enforcement is supplied, behaviour is byte for byte the upstream one - and that is the + * configuration of every node today. Same pattern as {@link CommitValidator}, deliberately: a + * second rendering of the same idea, written differently, diverges eventually. + */ public class PrepareValidator { private static final String ERROR_PREFIX = "Invalid Prepare Message"; @@ -36,9 +51,13 @@ public class PrepareValidator { private final Collection

validators; private final ConsensusRoundIdentifier targetRound; private final Hash expectedDigest; + // AERE full-PQ: optional enforcement, gated on height. Null means upstream behaviour, which is + // exactly what runs on every node today. + private final PqPrepareEnforcement pqEnforcement; /** - * Instantiates a new Prepare validator. + * Instantiates a new Prepare validator, self-wiring the AERE post-quantum enforcement from the + * system configuration. Without the arming property the hook is null and nothing changes. * * @param validators the validators * @param targetRound the target round @@ -48,9 +67,26 @@ public class PrepareValidator { final Collection
validators, final ConsensusRoundIdentifier targetRound, final Hash expectedDigest) { + this(validators, targetRound, expectedDigest, PqPrepareEnforcement.fromSystemConfig()); + } + + /** + * Instantiates a new Prepare validator with optional post-quantum enforcement. + * + * @param validators the validators + * @param targetRound the target round + * @param expectedDigest the expected digest + * @param pqEnforcement the height-gated enforcement, or null for upstream behaviour + */ + public PrepareValidator( + final Collection
validators, + final ConsensusRoundIdentifier targetRound, + final Hash expectedDigest, + final PqPrepareEnforcement pqEnforcement) { this.validators = validators; this.targetRound = targetRound; this.expectedDigest = expectedDigest; + this.pqEnforcement = pqEnforcement; } /** @@ -87,6 +123,22 @@ public class PrepareValidator { return false; } + // AERE full-PQ: from the armed height on, a PREPARE counts only with a valid post-quantum seal + // bound to THIS very author. Below it, or with no enforcement supplied, nothing changes. + if (pqEnforcement != null) { + final Optional refusal = + pqEnforcement.refusal( + targetRound.getSequenceNumber(), + targetRound.getRoundNumber(), + signedPayload.getAuthor(), + expectedDigest, + payload.getFalconSeal()); + if (refusal.isPresent()) { + LOG.info("{}: {}", ERROR_PREFIX, refusal.get()); + return false; + } + } + return true; } } diff --git a/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/ProposalPayloadValidator.java b/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/ProposalPayloadValidator.java index 58b768531..7d8c72076 100644 --- a/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/ProposalPayloadValidator.java +++ b/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/ProposalPayloadValidator.java @@ -11,6 +11,12 @@ * specific language governing permissions and limitations under the License. * * SPDX-License-Identifier: Apache-2.0 + * + * Modifications Copyright contributors to the Aere Network. + * + * This file was modified by contributors to the Aere Network, as required by section 4(b) of the + * Apache License 2.0. The copyright header above is the upstream one and is left exactly as it was + * found, as section 4(c) requires. The change itself is in patches/0003-aere-pq-anchor.patch. */ package org.hyperledger.besu.consensus.qbft.core.validation; @@ -30,7 +36,20 @@ import com.google.common.annotations.VisibleForTesting; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -/** The Proposal payload validator. */ +/** + * The Proposal payload validator. + * + *

AERE (2026-08-30): an OPTIONAL post-quantum enforcement hook, gated on height (see {@link + * PqProposalEnforcement}). When no enforcement is supplied, behaviour is byte for byte the upstream + * one - and that is the configuration of every node today. Same pattern as the PREPARE and commit + * validators, deliberately: a second rendering of the same idea, written differently, diverges + * eventually. + * + *

The 3-argument constructor - the one production code calls - wires the enforcement itself + * from the system configuration, exactly as {@link PrepareValidator} does. That self-wiring is the + * security property: an enforcement that had to be passed in explicitly could be dropped by any + * refactor without a single test failing. + */ public class ProposalPayloadValidator { private static final String ERROR_PREFIX = "Invalid Proposal Payload"; @@ -39,9 +58,11 @@ public class ProposalPayloadValidator { private final Address expectedProposer; private final ConsensusRoundIdentifier targetRound; private final QbftBlockValidator blockValidator; + private final PqProposalEnforcement pqEnforcement; /** - * Instantiates a new Proposal payload validator. + * Instantiates a new Proposal payload validator, with the post-quantum enforcement wired from + * the system configuration. This is the constructor production code calls. * * @param expectedProposer the expected proposer * @param targetRound the target round @@ -52,9 +73,28 @@ public class ProposalPayloadValidator { final Address expectedProposer, final ConsensusRoundIdentifier targetRound, final QbftBlockValidator blockValidator) { + this(expectedProposer, targetRound, blockValidator, PqProposalEnforcement.fromSystemConfig()); + } + + /** + * Instantiates a new Proposal payload validator with an explicit post-quantum enforcement. Test + * seam; production goes through the 3-argument constructor above. + * + * @param expectedProposer the expected proposer + * @param targetRound the target round + * @param blockValidator the block validator + * @param pqEnforcement the post-quantum enforcement, or null for upstream behaviour + */ + @VisibleForTesting + public ProposalPayloadValidator( + final Address expectedProposer, + final ConsensusRoundIdentifier targetRound, + final QbftBlockValidator blockValidator, + final PqProposalEnforcement pqEnforcement) { this.expectedProposer = expectedProposer; this.targetRound = targetRound; this.blockValidator = blockValidator; + this.pqEnforcement = pqEnforcement; } /** @@ -109,6 +149,23 @@ public class ProposalPayloadValidator { return false; } + // AERE full-PQ: from the armed height, a proposal is not accepted without a valid post-quantum + // seal from its OWN proposer. Below the height, and on every node without the property, this + // is a null check and nothing more - the upstream path, call for call. + if (pqEnforcement != null) { + final Optional refusal = + pqEnforcement.refusal( + payload.getRoundIdentifier().getSequenceNumber(), + payload.getRoundIdentifier().getRoundNumber(), + signedPayload.getAuthor(), + block.getHash(), + payload.getFalconSeal()); + if (refusal.isPresent()) { + LOG.info("{}: {}", ERROR_PREFIX, refusal.get()); + return false; + } + } + return true; } diff --git a/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/RoundChangePayloadValidator.java b/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/RoundChangePayloadValidator.java index 549acdd9c..8fd9bed3e 100644 --- a/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/RoundChangePayloadValidator.java +++ b/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/RoundChangePayloadValidator.java @@ -11,6 +11,12 @@ * specific language governing permissions and limitations under the License. * * SPDX-License-Identifier: Apache-2.0 + * + * Modifications Copyright contributors to the Aere Network. + * + * This file was modified by contributors to the Aere Network, as required by section 4(b) of the + * Apache License 2.0. The copyright header above is the upstream one and is left exactly as it was + * found, as section 4(c) requires. The change itself is in patches/0003-aere-pq-anchor.patch. */ package org.hyperledger.besu.consensus.qbft.core.validation; @@ -20,7 +26,9 @@ import org.hyperledger.besu.consensus.qbft.core.payload.RoundChangePayload; import org.hyperledger.besu.datatypes.Address; import java.util.Collection; +import java.util.Optional; +import com.google.common.annotations.VisibleForTesting; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -28,6 +36,19 @@ import org.slf4j.LoggerFactory; * Note: This does not validate that the received payload is for a future round, only that it was * signed by a known validator, and is for the current chain height. Future-round check must be * performed elsewhere (eg. the BlockHeightManager) + * + *

AERE (2026-08-31): an OPTIONAL post-quantum enforcement hook, gated on height (see {@link + * PqRoundChangeEnforcement}). When no enforcement is supplied, behaviour is byte for byte the + * upstream one - and that is the configuration of every node today. Same pattern as the PREPARE, + * commit and PROPOSAL validators, deliberately: a second rendering of the same idea, written + * differently, diverges eventually. + * + *

The 2-argument constructor - the one production code calls, from both {@code + * MessageValidatorFactory} and {@code ProposalValidator} (the justification path) - wires the + * enforcement itself from the system configuration. That self-wiring is the security property: an + * enforcement that had to be passed in explicitly could be dropped by any refactor without a + * single test failing. It also means round-changes are judged in BOTH places they arrive: + * standalone, and inside a proposal's round-change certificate. */ public class RoundChangePayloadValidator { @@ -36,16 +57,35 @@ public class RoundChangePayloadValidator { private final Collection

validators; private final long chainHeight; + private final PqRoundChangeEnforcement pqEnforcement; /** - * Instantiates a new Round change payload validator. + * Instantiates a new Round change payload validator, with the post-quantum enforcement wired + * from the system configuration. This is the constructor production code calls. * * @param validators the validators * @param chainHeight the chain height */ public RoundChangePayloadValidator(final Collection
validators, final long chainHeight) { + this(validators, chainHeight, PqRoundChangeEnforcement.fromSystemConfig()); + } + + /** + * Instantiates a new Round change payload validator with an explicit post-quantum enforcement. + * Test seam; production goes through the 2-argument constructor above. + * + * @param validators the validators + * @param chainHeight the chain height + * @param pqEnforcement the post-quantum enforcement, or null for upstream behaviour + */ + @VisibleForTesting + public RoundChangePayloadValidator( + final Collection
validators, + final long chainHeight, + final PqRoundChangeEnforcement pqEnforcement) { this.validators = validators; this.chainHeight = chainHeight; + this.pqEnforcement = pqEnforcement; } /** @@ -86,6 +126,24 @@ public class RoundChangePayloadValidator { return false; } } + + // AERE full-PQ: from the armed height, a round-change is not accepted without a valid + // post-quantum seal from its OWN author. Below the height, and on every node without the + // property, this is a null check and nothing more - the upstream path, call for call. + if (pqEnforcement != null) { + final Optional refusal = + pqEnforcement.refusal( + payload.getRoundIdentifier().getSequenceNumber(), + targetRound, + signedPayload.getAuthor(), + payload.getPreparedRoundMetadata(), + payload.getFalconSeal()); + if (refusal.isPresent()) { + LOG.info("{}: {}", ERROR_PREFIX, refusal.get()); + return false; + } + } + return true; } } diff --git a/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/network/ProposalSealPlumbingTest.java b/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/network/ProposalSealPlumbingTest.java new file mode 100755 index 000000000..4fe68e20f --- /dev/null +++ b/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/network/ProposalSealPlumbingTest.java @@ -0,0 +1,101 @@ +/* + * Copyright contributors to Besu. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.qbft.core.network; + +import static java.util.Collections.emptyList; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.verify; + +import org.hyperledger.besu.consensus.common.bft.ConsensusRoundIdentifier; +import org.hyperledger.besu.consensus.common.bft.FalconSeal; +import org.hyperledger.besu.consensus.common.bft.network.ValidatorMulticaster; +import org.hyperledger.besu.consensus.qbft.core.QbftBlockTestFixture; +import org.hyperledger.besu.consensus.qbft.core.messagedata.ProposalMessageData; +import org.hyperledger.besu.consensus.qbft.core.messagewrappers.Proposal; +import org.hyperledger.besu.consensus.qbft.core.payload.MessageFactory; +import org.hyperledger.besu.consensus.qbft.core.types.QbftBlock; +import org.hyperledger.besu.consensus.qbft.core.types.QbftBlockCodec; +import org.hyperledger.besu.consensus.qbft.core.types.QbftBlockHeader; +import org.hyperledger.besu.consensus.qbft.core.validation.QbftBlockHeaderTestFixture; +import org.hyperledger.besu.cryptoservices.NodeKeyUtils; +import org.hyperledger.besu.ethereum.p2p.rlpx.wire.MessageData; + +import java.util.Optional; + +import org.apache.tuweni.bytes.Bytes; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +/** + * THE PLUMBING between the local proposal and the one on the wire. + * + *

This test exists because the first network run of proposal enforcement (F84 scenario A, + * 2026-08-30) went red on its own WITNESS: every node emitted a seal on its local proposal, and + * the chain still stopped dead at the enforcement height, because {@code multicastProposal} + * re-creates the proposal from scratch and the wire copy carried no seal. Every unit test we had + * passed, since none of them looked at what actually leaves the node. This one does. + */ +@ExtendWith(MockitoExtension.class) +public class ProposalSealPlumbingTest { + + private static final ConsensusRoundIdentifier ROUND_ID = new ConsensusRoundIdentifier(1, 0); + + @Mock private QbftBlockCodec blockEncoder; + @Mock private QbftBlock block; + @Mock private ValidatorMulticaster multicaster; + + private Proposal sentProposal(final Optional seal) { + lenient().when(blockEncoder.readFrom(any())).thenReturn(block); + final MessageFactory factory = new MessageFactory(NodeKeyUtils.generate(), blockEncoder); + final QbftMessageTransmitter transmitter = new QbftMessageTransmitter(factory, multicaster); + + if (seal.isPresent()) { + transmitter.multicastProposal( + ROUND_ID, block, Optional.empty(), emptyList(), emptyList(), seal); + } else { + transmitter.multicastProposal(ROUND_ID, block, Optional.empty(), emptyList(), emptyList()); + } + + final ArgumentCaptor captor = ArgumentCaptor.forClass(MessageData.class); + verify(multicaster).send(captor.capture()); + return ProposalMessageData.fromMessageData(captor.getValue()).decode(blockEncoder); + } + + @Test + public void theSealHandedToTheTransmitterIsTheSealOnTheWire() { + final FalconSeal seal = new FalconSeal(4, Bytes.fromHexString("0xdeadbeef")); + final Proposal wire = sentProposal(Optional.of(seal)); + assertThat(wire.getSignedPayload().getPayload().getFalconSeal()).contains(seal); + } + + @Test + public void withoutASealTheWireProposalCarriesNone() { + final Proposal wire = sentProposal(Optional.empty()); + assertThat(wire.getSignedPayload().getPayload().getFalconSeal()).isEmpty(); + } + + /** Kept so the fixture imports stay honest if the wrappers change shape. */ + @Test + public void theFixturesStillBuildARealBlock() { + final QbftBlockHeader header = new QbftBlockHeaderTestFixture().number(1).buildHeader(); + final QbftBlock real = new QbftBlockTestFixture().blockHeader(header).build(); + assertThat(real.getHeader().getNumber()).isEqualTo(1); + } +} diff --git a/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/network/RoundChangeSealPlumbingTest.java b/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/network/RoundChangeSealPlumbingTest.java new file mode 100755 index 000000000..878925888 --- /dev/null +++ b/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/network/RoundChangeSealPlumbingTest.java @@ -0,0 +1,88 @@ +/* + * Copyright contributors to Besu. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.qbft.core.network; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; + +import org.hyperledger.besu.consensus.common.bft.ConsensusRoundIdentifier; +import org.hyperledger.besu.consensus.common.bft.FalconSeal; +import org.hyperledger.besu.consensus.common.bft.network.ValidatorMulticaster; +import org.hyperledger.besu.consensus.qbft.core.messagedata.RoundChangeMessageData; +import org.hyperledger.besu.consensus.qbft.core.messagewrappers.RoundChange; +import org.hyperledger.besu.consensus.qbft.core.payload.MessageFactory; +import org.hyperledger.besu.consensus.qbft.core.types.QbftBlockCodec; +import org.hyperledger.besu.cryptoservices.NodeKeyUtils; +import org.hyperledger.besu.ethereum.p2p.rlpx.wire.MessageData; + +import java.util.Optional; + +import org.apache.tuweni.bytes.Bytes; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +/** + * THE PLUMBING between the local round-change and the one on the wire. + * + *

This test exists because of what F84 measured on the PROPOSAL (2026-08-30): the transmitter + * re-created the message from scratch, so the local copy carried a seal and the wire copy did not, + * and every unit test passed because none looked at what actually leaves the node. The round-change + * path has the same shape - the height manager builds a local round-change AND the transmitter used + * to build its own - so it gets the same test: the sealed overload must put the EXACT object it was + * handed on the wire. + */ +@ExtendWith(MockitoExtension.class) +public class RoundChangeSealPlumbingTest { + + // A round-change targets a positive round. + private static final ConsensusRoundIdentifier ROUND_ID = new ConsensusRoundIdentifier(1, 1); + + @Mock private QbftBlockCodec blockEncoder; + @Mock private ValidatorMulticaster multicaster; + + private RoundChange sentRoundChange(final Optional seal) { + final MessageFactory factory = new MessageFactory(NodeKeyUtils.generate(), blockEncoder); + final QbftMessageTransmitter transmitter = new QbftMessageTransmitter(factory, multicaster); + + if (seal.isPresent()) { + // The sealed path: the caller builds ONCE and the transmitter must not rebuild - Falcon + // signatures are randomised, so a rebuild would put a different object on the wire. + final RoundChange built = factory.createRoundChange(ROUND_ID, Optional.empty(), seal); + transmitter.multicastRoundChange(built); + } else { + transmitter.multicastRoundChange(ROUND_ID, Optional.empty()); + } + + final ArgumentCaptor captor = ArgumentCaptor.forClass(MessageData.class); + verify(multicaster).send(captor.capture()); + return RoundChangeMessageData.fromMessageData(captor.getValue()).decode(blockEncoder); + } + + @Test + public void theSealHandedToTheTransmitterIsTheSealOnTheWire() { + final FalconSeal seal = new FalconSeal(4, Bytes.fromHexString("0xdeadbeef")); + final RoundChange wire = sentRoundChange(Optional.of(seal)); + assertThat(wire.getSignedPayload().getPayload().getFalconSeal()).contains(seal); + } + + @Test + public void withoutASealTheWireRoundChangeCarriesNone() { + final RoundChange wire = sentRoundChange(Optional.empty()); + assertThat(wire.getSignedPayload().getPayload().getFalconSeal()).isEmpty(); + } +} diff --git a/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/payload/CommitPayloadHybridTest.java b/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/payload/CommitPayloadHybridTest.java new file mode 100755 index 000000000..ec0f88451 --- /dev/null +++ b/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/payload/CommitPayloadHybridTest.java @@ -0,0 +1,277 @@ +/* AERE HYBRID, step 1: the hybrid certificate inside the commit message. + * + * The proof that matters most comes FIRST: the golden vectors. They were measured on the + * binary from BEFORE this change (2026-08-24, by printing the encoding of a CommitPayload + * built from fixed values) and are copied here to be immovable. As long as they stay green, + * a commit without extras encodes exactly as on the live fleet, so the new binary can be + * warmed on a real node with no flag day. If anyone ever changes the base encoding, they + * turn red before the change can reach the chain. + * + * The rest proves the hybrid is truly hybrid: REAL Falcon plus REAL SLH-DSA, two unrelated + * mathematical families in the same message, each verified with its own scheme. */ +package org.hyperledger.besu.consensus.qbft.core.payload; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.hyperledger.besu.consensus.common.bft.ConsensusRoundIdentifier; +import org.hyperledger.besu.consensus.common.bft.FalconSeal; +import org.hyperledger.besu.consensus.common.bft.PqAnchorV2; +import org.hyperledger.besu.consensus.common.bft.SchemeSeal; +import org.hyperledger.besu.consensus.common.bft.SealScheme; +import org.hyperledger.besu.consensus.common.bft.SealSchemes; +import org.hyperledger.besu.crypto.SECPSignature; +import org.hyperledger.besu.crypto.SecureRandomProvider; +import org.hyperledger.besu.crypto.SignatureAlgorithmFactory; +import org.hyperledger.besu.datatypes.Hash; +import org.hyperledger.besu.ethereum.rlp.BytesValueRLPInput; +import org.hyperledger.besu.ethereum.rlp.BytesValueRLPOutput; +import org.hyperledger.besu.ethereum.rlp.RLPException; + +import java.security.SecureRandom; +import java.util.List; +import java.util.Optional; + +import org.apache.tuweni.bytes.Bytes; +import org.junit.jupiter.api.Test; + +public class CommitPayloadHybridTest { + + // ---- VECTORII DE AUR, masurati pe binarul de DINAINTE de aceasta schimbare ------------------- + private static final String AUR_FARA_PQ = + "0xf8660703a0000000000000000000000000000000000000000000000000000000000000002a" + + "b8410101010101010101010101010101010101010101010101010101010101010101" + + "020202020202020202020202020202020202020202020202020202020202020200"; + private static final String AUR_CU_FALCON = + "0xf86d0703a0000000000000000000000000000000000000000000000000000000000000002a" + + "b8410101010101010101010101010101010101010101010101010101010101010101" + + "020202020202020202020202020202020202020202020202020202020202020200" + + "c60384deadbeef"; + private static final String AUR_HASH_FARA_PQ = + "0xe4e36f241e03352338d89a5a0c98a59c5595d034f5a5d10e2316239ee0aecc40"; + private static final String AUR_HASH_CU_FALCON = + "0x03e71e08f2b72cde0e493edfce8e26529a34b92c3c0daf0ab3591a175aec4b13"; + + private static final ConsensusRoundIdentifier ROUND = new ConsensusRoundIdentifier(7L, 3); + private static final Hash DIGEST = Hash.fromHexStringLenient("0x2a"); + private static final FalconSeal FALCON_AUR = + new FalconSeal(3, Bytes.fromHexString("0xdeadbeef")); + + private final SecureRandom random = SecureRandomProvider.createSecureRandom(); + + private static SECPSignature ecdsa() { + return SignatureAlgorithmFactory.getInstance() + .decodeSignature( + Bytes.fromHexString( + "0x" + + "0101010101010101010101010101010101010101010101010101010101010101" + + "0202020202020202020202020202020202020202020202020202020202020202" + + "00")); + } + + private static CommitPayload prinCodec(final CommitPayload original) { + final BytesValueRLPOutput out = new BytesValueRLPOutput(); + original.writeTo(out); + return CommitPayload.readFrom(new BytesValueRLPInput(out.encoded(), false)); + } + + // ============================================================ 1. LACATUL: flota vie neatinsa + + @Test + public void aCommitWithoutPqEncodesExactlyAsTheLiveFleetDoes() { + final CommitPayload p = new CommitPayload(ROUND, DIGEST, ecdsa()); + assertThat(p.encoded().toHexString()).isEqualTo(AUR_FARA_PQ); + assertThat(p.hashForSignature().toHexString()).isEqualTo(AUR_HASH_FARA_PQ); + } + + @Test + public void aFalconOnlyCommitEncodesExactlyAsTheLiveFleetDoes() { + final CommitPayload p = + new CommitPayload(ROUND, DIGEST, ecdsa(), Optional.of(FALCON_AUR)); + assertThat(p.encoded().toHexString()).isEqualTo(AUR_CU_FALCON); + assertThat(p.hashForSignature().toHexString()).isEqualTo(AUR_HASH_CU_FALCON); + } + + @Test + public void theGoldenBytesOfTheLiveFleetStillDecode() { + final CommitPayload p = + CommitPayload.readFrom( + new BytesValueRLPInput(Bytes.fromHexString(AUR_CU_FALCON), false)); + assertThat(p.getFalconSeal()).contains(FALCON_AUR); + assertThat(p.getExtraSeals()).isEmpty(); + } + + // ============================================================ 2. hibridul, cu crypto REALA + + @Test + public void aRealHybridCertificateSurvivesTheRoundTrip() { + final SealScheme.GeneratedPair slh = SealSchemes.SLH_DSA_128S.generate(random); + final byte[] sig = + SealSchemes.SLH_DSA_128S.sign(slh.privateKey(), DIGEST.getBytes().toArray()).orElseThrow(); + final SchemeSeal extra = + new SchemeSeal(SealSchemes.SLH_DSA_128S.wireId(), 3, Bytes.wrap(sig)); + + final CommitPayload original = + new CommitPayload(ROUND, DIGEST, ecdsa(), Optional.of(FALCON_AUR), List.of(extra)); + final CommitPayload back = prinCodec(original); + + assertThat(back).isEqualTo(original); + assertThat(back.getFalconSeal()).contains(FALCON_AUR); + assertThat(back.getExtraSeals()).hasSize(1); + // si semnatura chiar se verifica dupa drumul prin codec, cu SCHEMA ei + assertThat( + SealSchemes.SLH_DSA_128S.verifyRaw( + slh.publicRegistryForm(), + DIGEST.getBytes().toArray(), + back.getExtraSeals().get(0).getSignature().toArray())) + .isTrue(); + } + + @Test + public void twoUnrelatedFamiliesTravelInOneCommitAndEachVerifiesWithItsOwn() { + final SealScheme.GeneratedPair falcon = SealSchemes.FALCON_512.generate(random); + final SealScheme.GeneratedPair slh = SealSchemes.SLH_DSA_128S.generate(random); + final byte[] message = DIGEST.getBytes().toArray(); + final byte[] sigFalcon = SealSchemes.FALCON_512.sign(falcon.privateKey(), message).orElseThrow(); + final byte[] sigSlh = SealSchemes.SLH_DSA_128S.sign(slh.privateKey(), message).orElseThrow(); + + final CommitPayload p = + new CommitPayload( + ROUND, + DIGEST, + ecdsa(), + Optional.of(new FalconSeal(3, Bytes.wrap(sigFalcon))), + List.of(new SchemeSeal(SealSchemes.SLH_DSA_128S.wireId(), 3, Bytes.wrap(sigSlh)))); + final CommitPayload back = prinCodec(p); + + assertThat( + SealSchemes.FALCON_512.verifyRaw( + falcon.publicRegistryForm(), + message, + back.getFalconSeal().orElseThrow().getSignature().toArray())) + .isTrue(); + assertThat( + SealSchemes.SLH_DSA_128S.verifyRaw( + slh.publicRegistryForm(), + message, + back.getExtraSeals().get(0).getSignature().toArray())) + .isTrue(); + // the CROSSED CONTROL: each signature is refused by the OTHER scheme, so the hybrid + // really stands on two legs and not on the same leg twice + assertThat(SealSchemes.SLH_DSA_128S.verifyRaw(slh.publicRegistryForm(), message, sigFalcon)) + .isFalse(); + assertThat(SealSchemes.FALCON_512.verifyRaw(falcon.publicRegistryForm(), message, sigSlh)) + .isFalse(); + } + + @Test + public void theEcdsaSignedBytesCoverTheExtras() { + final SchemeSeal extra = + new SchemeSeal(SealSchemes.SLH_DSA_128S.wireId(), 3, Bytes.fromHexString("0xabcdef")); + final CommitPayload faraExtras = + new CommitPayload(ROUND, DIGEST, ecdsa(), Optional.of(FALCON_AUR)); + final CommitPayload cuExtras = + new CommitPayload(ROUND, DIGEST, ecdsa(), Optional.of(FALCON_AUR), List.of(extra)); + // if the hash were the same, extras could be added or removed by anyone without + // breaking the author's signature + assertThat(cuExtras.hashForSignature()).isNotEqualTo(faraExtras.hashForSignature()); + } + + // ============================================================ 3. refuzurile + + @Test + public void extrasWithoutAFalconSealAreRefusedAtConstruction() { + final SchemeSeal extra = + new SchemeSeal(SealSchemes.SLH_DSA_128S.wireId(), 3, Bytes.fromHexString("0xabcdef")); + assertThatThrownBy( + () -> new CommitPayload(ROUND, DIGEST, ecdsa(), Optional.empty(), List.of(extra))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("require the Falcon seal"); + } + + @Test + public void falconInTheExtrasIsRefusedSoOneSignatureHasOneHome() { + final SchemeSeal falconInExtras = + new SchemeSeal(SealSchemes.FALCON_512.wireId(), 3, Bytes.fromHexString("0xabcdef")); + assertThatThrownBy( + () -> + new CommitPayload( + ROUND, DIGEST, ecdsa(), Optional.of(FALCON_AUR), List.of(falconInExtras))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("own slot"); + } + + @Test + public void anEmptyExtrasElementOnTheWireIsRefused() { + // doua codificari ale aceleiasi valori nu au voie sa existe: extras gol == extras absent + final BytesValueRLPOutput out = new BytesValueRLPOutput(); + out.startList(); + out.writeLongScalar(ROUND.getSequenceNumber()); + out.writeIntScalar(ROUND.getRoundNumber()); + out.writeBytes(DIGEST.getBytes()); + out.writeBytes(ecdsa().encodedBytes()); + out.startList(); + out.writeIntScalar(FALCON_AUR.getValidatorIndex()); + out.writeBytes(FALCON_AUR.getSignature()); + out.endList(); + out.writeRaw(PqAnchorV2.encode(List.of())); + out.endList(); + + assertThatThrownBy( + () -> CommitPayload.readFrom(new BytesValueRLPInput(out.encoded(), false))) + .isInstanceOf(RLPException.class); + } + + @Test + public void aThirdTrailingElementIsRefusedByCanonicality() { + final BytesValueRLPOutput out = new BytesValueRLPOutput(); + out.startList(); + out.writeLongScalar(ROUND.getSequenceNumber()); + out.writeIntScalar(ROUND.getRoundNumber()); + out.writeBytes(DIGEST.getBytes()); + out.writeBytes(ecdsa().encodedBytes()); + out.startList(); + out.writeIntScalar(FALCON_AUR.getValidatorIndex()); + out.writeBytes(FALCON_AUR.getSignature()); + out.endList(); + out.writeRaw( + PqAnchorV2.encode( + List.of( + new SchemeSeal( + SealSchemes.SLH_DSA_128S.wireId(), 3, Bytes.fromHexString("0xabcdef"))))); + out.writeBytes(Bytes.fromHexString("0x99")); // al treilea element, nu exista in format + out.endList(); + + assertThatThrownBy( + () -> CommitPayload.readFrom(new BytesValueRLPInput(out.encoded(), false))) + .isInstanceOf(RLPException.class); + } + + @Test + public void aCorruptedExtrasElementIsAnRlpFailureNotACrash() { + final BytesValueRLPOutput out = new BytesValueRLPOutput(); + out.startList(); + out.writeLongScalar(ROUND.getSequenceNumber()); + out.writeIntScalar(ROUND.getRoundNumber()); + out.writeBytes(DIGEST.getBytes()); + out.writeBytes(ecdsa().encodedBytes()); + out.startList(); + out.writeIntScalar(FALCON_AUR.getValidatorIndex()); + out.writeBytes(FALCON_AUR.getSignature()); + out.endList(); + // un element care NU e un certificat v2: versiune necunoscuta + final BytesValueRLPOutput bad = new BytesValueRLPOutput(); + bad.startList(); + bad.writeIntScalar(99); + bad.startList(); + bad.endList(); + bad.endList(); + out.writeRaw(bad.encoded()); + out.endList(); + + assertThatThrownBy( + () -> CommitPayload.readFrom(new BytesValueRLPInput(out.encoded(), false))) + .isInstanceOf(RLPException.class) + .hasMessageContaining("AERE HIBRID"); + } +} diff --git a/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/payload/PreparePayloadPqTest.java b/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/payload/PreparePayloadPqTest.java new file mode 100755 index 000000000..d2f91c43c --- /dev/null +++ b/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/payload/PreparePayloadPqTest.java @@ -0,0 +1,209 @@ +/* + * Copyright contributors to Besu. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.qbft.core.payload; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.hyperledger.besu.consensus.common.bft.ConsensusRoundIdentifier; +import org.hyperledger.besu.consensus.common.bft.FalconSeal; +import org.hyperledger.besu.consensus.common.bft.PqAnchor; +import org.hyperledger.besu.datatypes.Hash; +import org.hyperledger.besu.ethereum.rlp.BytesValueRLPInput; +import org.hyperledger.besu.ethereum.rlp.BytesValueRLPOutput; +import org.hyperledger.besu.ethereum.rlp.RLPException; + +import java.util.Optional; + +import org.apache.tuweni.bytes.Bytes; +import org.apache.tuweni.bytes.Bytes32; +import org.junit.jupiter.api.Test; + +/** + * STEP 1 of PREPARE-SI-ROUNDCHANGE-SUB-PQ-PROIECTARE-2026-08-28: the wire can carry a + * post-quantum seal on a PREPARE, and NOTHING emits one yet. + * + *

The test that matters most is the first one: a PREPARE without a seal encodes EXACTLY as it + * did before this change. Without it the new binary cannot be rolled onto a live fleet, because the + * neighbours would compute a different signature hash and reject every PREPARE. The golden vector + * is built from the canonical encoding of the OLD form, not from a run of the new code. + */ +public class PreparePayloadPqTest { + + private static final ConsensusRoundIdentifier ROUND = new ConsensusRoundIdentifier(7, 3); + private static final Hash DIGEST = + Hash.wrap( + Bytes32.fromHexString( + "0x000000000000000000000000000000000000000000000000000000000000002a")); + + /** + * THE GOLDEN VECTOR of the old form: RLP[ sequence, round, digest(32) ]. Built here from its + * elements, not copied from a run, so that what it is made of stays visible. + * + *

The first version of this test wrapped sequence and round in a LIST, and it failed. The + * source (QbftPayload.writeConsensusRound) writes them as two FLAT scalars. The test was the + * wrong one, not the code - and that is worth saying, because a golden vector written from + * intuition instead of from the source would have either refused good code or, worse, been + * "fixed" by moving the code to match it. + */ + private static Bytes goldenOldForm() { + final BytesValueRLPOutput out = new BytesValueRLPOutput(); + out.startList(); + out.writeLongScalar(7L); + out.writeIntScalar(3); + out.writeBytes(DIGEST.getBytes()); + out.endList(); + return out.encoded(); + } + + @Test + public void aPrepareWithoutASealEncodesEXACTLYAsBefore() { + final PreparePayload p = new PreparePayload(ROUND, DIGEST); + assertThat(p.encoded()).isEqualTo(goldenOldForm()); + // and the signature hash, which is precisely what binds the author to the message + assertThat(p.hashForSignature()) + .isEqualTo(new PreparePayload(ROUND, DIGEST, Optional.empty()).hashForSignature()); + } + + @Test + public void aPrepareWithoutASealReadsBackIdentical() { + final Bytes encoded = new PreparePayload(ROUND, DIGEST).encoded(); + final PreparePayload decoded = PreparePayload.readFrom(new BytesValueRLPInput(encoded, false)); + assertThat(decoded.getFalconSeal()).isEmpty(); + assertThat(decoded.getDigest()).isEqualTo(DIGEST); + assertThat(decoded.getRoundIdentifier()).isEqualTo(ROUND); + assertThat(decoded.encoded()).isEqualTo(encoded); + } + + @Test + public void aPrepareWithASealReadsBackIdentical() { + final FalconSeal seal = new FalconSeal(4, Bytes.fromHexString("0xdeadbeef")); + final PreparePayload p = new PreparePayload(ROUND, DIGEST, Optional.of(seal)); + final Bytes encoded = p.encoded(); + + // it is longer than the old form, and CONTAINS it as a prefix of the content + assertThat(encoded.size()).isGreaterThan(goldenOldForm().size()); + + final PreparePayload decoded = PreparePayload.readFrom(new BytesValueRLPInput(encoded, false)); + assertThat(decoded.getFalconSeal()).isPresent(); + assertThat(decoded.getFalconSeal().get().getValidatorIndex()).isEqualTo(4); + assertThat(decoded.getFalconSeal().get().getSignature()).isEqualTo(Bytes.fromHexString("0xdeadbeef")); + assertThat(decoded).isEqualTo(p); + assertThat(decoded.encoded()).isEqualTo(encoded); + } + + @Test + public void aSealChangesTheSignatureHash() { + // If it did not change it, the author's ECDSA signature would not cover the seal, and anyone + // could paste a foreign index onto an otherwise valid PREPARE. + final PreparePayload without = new PreparePayload(ROUND, DIGEST); + final PreparePayload with = + new PreparePayload(ROUND, DIGEST, Optional.of(new FalconSeal(4, Bytes.fromHexString("0xdeadbeef")))); + assertThat(with.hashForSignature()).isNotEqualTo(without.hashForSignature()); + } + + @Test + public void aNONCANONICALEncodingIsRefused() { + // A third element that is not a seal: the decoder could ignore it, and then two different byte + // strings would authenticate to the same validator. It is refused. + final BytesValueRLPOutput out = new BytesValueRLPOutput(); + out.startList(); + out.writeLongScalar(7L); + out.writeIntScalar(3); + out.writeBytes(DIGEST.getBytes()); + out.startList(); + out.writeIntScalar(4); + out.writeBytes(Bytes.fromHexString("0xdeadbeef")); + out.writeBytes(Bytes.fromHexString("0xff")); // element in plus INAUNTRUL sigiliului + out.endList(); + out.endList(); + + assertThatThrownBy(() -> PreparePayload.readFrom(new BytesValueRLPInput(out.encoded(), false))) + .isInstanceOf(RLPException.class); + } + + // ---- domain separation: the security part of the design ------------------------------------- + + @Test + public void thePREPAREMessageIsNotTheCOMMITMessage() { + // If it were the same, a PREPARE seal given HONESTLY could be pasted onto a forged COMMIT and + // the enforcement there would accept it. That is exactly the attack this separation closes. + final Bytes32 prep = PqAnchor.prepareMessage(2800L, 100L, 3, DIGEST.getBytes()); + final Bytes32 comm = PqAnchor.commitMessage(2800L, 100L, DIGEST.getBytes()); + assertThat(prep).isNotEqualTo(comm); + } + + @Test + public void thePREPAREMessageDependsOnTheROUND() { + // Two PREPAREs for the same block in different rounds are two different assertions. Without the + // round in the preimage, a seal from a failed round would justify another one. + final Bytes32 r3 = PqAnchor.prepareMessage(2800L, 100L, 3, DIGEST.getBytes()); + final Bytes32 r4 = PqAnchor.prepareMessage(2800L, 100L, 4, DIGEST.getBytes()); + assertThat(r3).isNotEqualTo(r4); + } + + @Test + public void thePREPAREMessageDependsOnCHAINAndHEIGHT() { + final Bytes32 baza = PqAnchor.prepareMessage(2800L, 100L, 3, DIGEST.getBytes()); + assertThat(PqAnchor.prepareMessage(2801L, 100L, 3, DIGEST.getBytes())).isNotEqualTo(baza); + assertThat(PqAnchor.prepareMessage(2800L, 101L, 3, DIGEST.getBytes())).isNotEqualTo(baza); + } + + @Test + public void thePREPAREMessageRefusesImpossibleInputs() { + assertThatThrownBy(() -> PqAnchor.prepareMessage(2800L, -1L, 3, DIGEST.getBytes())) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> PqAnchor.prepareMessage(2800L, 100L, -1, DIGEST.getBytes())) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> PqAnchor.prepareMessage(2800L, 100L, 3, Bytes.fromHexString("0x00"))) + .isInstanceOf(IllegalArgumentException.class); + } + + // =============================================================================================== + // THE BRIDGE TO CLIENT 2, added 2026-08-29 (finding D-282). + // + // The two strings below are written LITERALLY in client 2's test as well + // (AereQbftPrepareSealWireProofTests), for the same values. This is not a round trip: each + // implementation encodes the payload on its own and compares it with THE SAME string. If either + // one moves, one of the two tests fails - and that is exactly the question that matters, because + // a client-2 decoder strict at three elements would have rejected every PREPARE of a fleet with + // emission armed, exactly as its decoder strict at four rejected every commit at an anchor + // height. + // + // And so that this is not two implementations being wrong in the same way, the bytes were checked + // with a THIRD RLP encoder as well, written separately in python, with no connection to either + // project: both strings matched exactly. + // + // The structure, so it can be read by eye: + // e3 | 07 | 03 | a0 <32 digest bytes> = old form, three elements + // ea | 07 | 03 | a0 <32 digest bytes> | c6 04 84 deadbeef = with a seal, four elements + // =============================================================================================== + + private static final String AUR_FARA_SIGILIU = + "0xe30703a0000000000000000000000000000000000000000000000000000000000000002a"; + private static final String AUR_CU_SIGILIU = + "0xea0703a0000000000000000000000000000000000000000000000000000000000000002ac60484deadbeef"; + + @Test + public void theWireBytesAreTHESAMEAsInClient2sTest() { + assertThat(new PreparePayload(ROUND, DIGEST).encoded()) + .isEqualTo(Bytes.fromHexString(AUR_FARA_SIGILIU)); + + final PreparePayload with = + new PreparePayload( + ROUND, DIGEST, Optional.of(new FalconSeal(4, Bytes.fromHexString("0xdeadbeef")))); + assertThat(with.encoded()).isEqualTo(Bytes.fromHexString(AUR_CU_SIGILIU)); + } +} diff --git a/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/payload/ProposalPayloadPqTest.java b/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/payload/ProposalPayloadPqTest.java new file mode 100755 index 000000000..c6b6de065 --- /dev/null +++ b/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/payload/ProposalPayloadPqTest.java @@ -0,0 +1,202 @@ +/* + * Copyright contributors to Besu. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.qbft.core.payload; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; + +import org.hyperledger.besu.consensus.common.bft.ConsensusRoundIdentifier; +import org.hyperledger.besu.consensus.common.bft.FalconSeal; +import org.hyperledger.besu.consensus.common.bft.PqAnchor; +import org.hyperledger.besu.consensus.qbft.core.types.QbftBlock; +import org.hyperledger.besu.consensus.qbft.core.types.QbftBlockCodec; +import org.hyperledger.besu.ethereum.rlp.BytesValueRLPInput; +import org.hyperledger.besu.ethereum.rlp.BytesValueRLPOutput; +import org.hyperledger.besu.ethereum.rlp.RLPException; + +import java.util.Optional; + +import org.apache.tuweni.bytes.Bytes; +import org.apache.tuweni.bytes.Bytes32; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +/** + * The wire can carry a post-quantum seal on a PROPOSAL, and NOTHING emits one yet (AERE PQ, + * 2026-08-30, the hot-path step after PREPARE). + * + *

The test that matters most is the first one: a proposal without a seal encodes EXACTLY as + * upstream does. The block codec is mocked and contributes zero bytes on both sides of the round + * trip, which is what lets the surrounding list structure be compared against a hand-built + * expected form instead of against a run of the code under test. + * + *

The strictness tests close the malleability door this file's javadoc names: an authenticated + * payload whose decoder silently ignored trailing elements would let two different byte strings + * authenticate to the same proposer. + */ +@ExtendWith(MockitoExtension.class) +public class ProposalPayloadPqTest { + + private static final ConsensusRoundIdentifier ROUND_ID = new ConsensusRoundIdentifier(7, 3); + + @Mock private QbftBlockCodec blockEncoder; + @Mock private QbftBlock block; + + @BeforeEach + void wireCodec() { + // The mock codec writes nothing and reads nothing: the block contributes zero elements on both + // sides, so the tests compare the STRUCTURE around it, which is what this file changed. + lenient().when(blockEncoder.readFrom(any())).thenReturn(block); + } + + /** The old form with the mocked block: RLP[ sequence, round, null-BAL ]. Built by hand. */ + private static Bytes oldForm() { + final BytesValueRLPOutput out = new BytesValueRLPOutput(); + out.startList(); + out.writeLongScalar(ROUND_ID.getSequenceNumber()); + out.writeIntScalar(ROUND_ID.getRoundNumber()); + out.writeNull(); + out.endList(); + return out.encoded(); + } + + @Test + public void aProposalWithoutASealEncodesEXACTLYAsBefore() { + final ProposalPayload p = new ProposalPayload(ROUND_ID, block, blockEncoder, Optional.empty()); + assertThat(p.encoded()).isEqualTo(oldForm()); + // and the signature hash, which is precisely what binds the proposer to the message + assertThat(p.hashForSignature()) + .isEqualTo( + new ProposalPayload(ROUND_ID, block, blockEncoder, Optional.empty(), Optional.empty()) + .hashForSignature()); + } + + @Test + public void aProposalWithoutASealReadsBackIdentical() { + final Bytes encoded = new ProposalPayload(ROUND_ID, block, blockEncoder, Optional.empty()).encoded(); + final ProposalPayload read = + ProposalPayload.readFrom(new BytesValueRLPInput(encoded, false), blockEncoder); + assertThat(read.getFalconSeal()).isEmpty(); + assertThat(read.getRoundIdentifier()).isEqualTo(ROUND_ID); + assertThat(read.encoded()).isEqualTo(encoded); + } + + @Test + public void aProposalWithASealReadsBackIdentical() { + final FalconSeal seal = new FalconSeal(4, Bytes.fromHexString("0xdeadbeef")); + final ProposalPayload p = + new ProposalPayload(ROUND_ID, block, blockEncoder, Optional.empty(), Optional.of(seal)); + final Bytes encoded = p.encoded(); + + // it is longer than the old form, and CONTAINS it as a prefix of the content + assertThat(encoded.size()).isGreaterThan(oldForm().size()); + + final ProposalPayload read = + ProposalPayload.readFrom(new BytesValueRLPInput(encoded, false), blockEncoder); + assertThat(read.getFalconSeal()).isPresent(); + assertThat(read.getFalconSeal().get().getValidatorIndex()).isEqualTo(4); + assertThat(read.getFalconSeal().get().getSignature()) + .isEqualTo(Bytes.fromHexString("0xdeadbeef")); + assertThat(read.encoded()).isEqualTo(encoded); + } + + @Test + public void aSealChangesTheSignatureHash() { + // If it did not change it, the proposer's ECDSA signature would not cover the seal, and anyone + // could paste a foreign index onto an otherwise valid proposal. + final ProposalPayload without = new ProposalPayload(ROUND_ID, block, blockEncoder, Optional.empty()); + final ProposalPayload with = + new ProposalPayload( + ROUND_ID, + block, + blockEncoder, + Optional.empty(), + Optional.of(new FalconSeal(4, Bytes.fromHexString("0xdeadbeef")))); + assertThat(with.hashForSignature()).isNotEqualTo(without.hashForSignature()); + } + + @Test + public void anElementAfterTheSealIsRefused() { + // A decoder that ignored it would re-encode without it: two different byte strings would + // authenticate to the same proposer. It is refused. + final BytesValueRLPOutput out = new BytesValueRLPOutput(); + out.startList(); + out.writeLongScalar(ROUND_ID.getSequenceNumber()); + out.writeIntScalar(ROUND_ID.getRoundNumber()); + out.writeNull(); + out.startList(); + out.writeIntScalar(4); + out.writeBytes(Bytes.fromHexString("0xdeadbeef")); + out.endList(); + out.writeIntScalar(1); // the trailing element nothing accounts for + out.endList(); + + assertThatThrownBy( + () -> ProposalPayload.readFrom(new BytesValueRLPInput(out.encoded(), false), blockEncoder)) + .isInstanceOf(RLPException.class); + } + + @Test + public void aFifthElementThatIsNotASealIsRefused() { + final BytesValueRLPOutput out = new BytesValueRLPOutput(); + out.startList(); + out.writeLongScalar(ROUND_ID.getSequenceNumber()); + out.writeIntScalar(ROUND_ID.getRoundNumber()); + out.writeNull(); + out.writeBytes(Bytes.fromHexString("0x01")); // not a [index, signature] list + out.endList(); + + assertThatThrownBy( + () -> ProposalPayload.readFrom(new BytesValueRLPInput(out.encoded(), false), blockEncoder)) + .isInstanceOf(RLPException.class); + } + + // ---- domain separation: the security part of the design -------------------------------------- + + private static final Bytes32 DIGEST = + Bytes32.fromHexString("0x000000000000000000000000000000000000000000000000000000000000002a"); + + @Test + public void theProposalMessageIsNeitherThePrepareNorTheCommitMessage() { + // If any two were the same, a seal given HONESTLY in one role could be replayed in the other: + // an offer counted as a vote, or a vote replayed as an offer. + final Bytes32 proposal = PqAnchor.proposalMessage(2800L, 100L, 3, DIGEST); + assertThat(proposal).isNotEqualTo(PqAnchor.prepareMessage(2800L, 100L, 3, DIGEST)); + assertThat(proposal).isNotEqualTo(PqAnchor.commitMessage(2800L, 100L, DIGEST)); + } + + @Test + public void theProposalMessageDependsOnROUNDChainAndHeight() { + final Bytes32 base = PqAnchor.proposalMessage(2800L, 100L, 3, DIGEST); + assertThat(PqAnchor.proposalMessage(2800L, 100L, 4, DIGEST)).isNotEqualTo(base); + assertThat(PqAnchor.proposalMessage(2801L, 100L, 3, DIGEST)).isNotEqualTo(base); + assertThat(PqAnchor.proposalMessage(2800L, 101L, 3, DIGEST)).isNotEqualTo(base); + } + + @Test + public void theProposalMessageRefusesImpossibleInputs() { + assertThatThrownBy(() -> PqAnchor.proposalMessage(2800L, -1L, 3, DIGEST)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> PqAnchor.proposalMessage(2800L, 100L, -1, DIGEST)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> PqAnchor.proposalMessage(2800L, 100L, 3, Bytes.fromHexString("0x00"))) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/payload/RoundChangePayloadPqTest.java b/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/payload/RoundChangePayloadPqTest.java new file mode 100755 index 000000000..efbf51766 --- /dev/null +++ b/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/payload/RoundChangePayloadPqTest.java @@ -0,0 +1,236 @@ +/* + * Copyright contributors to Besu. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.qbft.core.payload; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.hyperledger.besu.consensus.common.bft.ConsensusRoundIdentifier; +import org.hyperledger.besu.consensus.common.bft.FalconSeal; +import org.hyperledger.besu.consensus.common.bft.PqAnchor; +import org.hyperledger.besu.datatypes.Hash; +import org.hyperledger.besu.ethereum.rlp.BytesValueRLPInput; +import org.hyperledger.besu.ethereum.rlp.BytesValueRLPOutput; +import org.hyperledger.besu.ethereum.rlp.RLPException; + +import java.util.Optional; + +import org.apache.tuweni.bytes.Bytes; +import org.apache.tuweni.bytes.Bytes32; +import org.junit.jupiter.api.Test; + +/** + * The wire can carry a post-quantum seal on a ROUND-CHANGE, and NOTHING emits one yet (AERE PQ, + * 2026-08-31, the last hot-path message). + * + *

The test that matters most is the first one: a round-change without a seal encodes EXACTLY as + * upstream does, compared against a hand-built expected form instead of against a run of the code + * under test. + * + *

The strictness tests close the malleability door the payload's javadoc names: an + * authenticated payload whose decoder silently ignored trailing elements would let two different + * byte strings authenticate to the same author. + */ +public class RoundChangePayloadPqTest { + + private static final ConsensusRoundIdentifier ROUND_ID = new ConsensusRoundIdentifier(7, 3); + private static final Hash PREPARED_HASH = Hash.hash(Bytes.of(1, 2, 3)); + + /** The old bare form: RLP[ sequence, round, [] ]. Built by hand. */ + private static Bytes oldBareForm() { + final BytesValueRLPOutput out = new BytesValueRLPOutput(); + out.startList(); + out.writeLongScalar(ROUND_ID.getSequenceNumber()); + out.writeIntScalar(ROUND_ID.getRoundNumber()); + out.startList(); + out.endList(); + out.endList(); + return out.encoded(); + } + + @Test + public void aRoundChangeWithoutASealEncodesEXACTLYAsBefore() { + final RoundChangePayload p = new RoundChangePayload(ROUND_ID, Optional.empty()); + assertThat(p.encoded()).isEqualTo(oldBareForm()); + // and the signature hash, which is precisely what binds the author to the message + assertThat(p.hashForSignature()) + .isEqualTo( + new RoundChangePayload(ROUND_ID, Optional.empty(), Optional.empty()).hashForSignature()); + } + + @Test + public void aRoundChangeWithPreparedMetadataAndNoSealReadsBackIdentical() { + final RoundChangePayload p = + new RoundChangePayload( + ROUND_ID, Optional.of(new PreparedRoundMetadata(PREPARED_HASH, 1))); + final Bytes encoded = p.encoded(); + final RoundChangePayload read = + RoundChangePayload.readFrom(new BytesValueRLPInput(encoded, false)); + assertThat(read.getFalconSeal()).isEmpty(); + assertThat(read.getPreparedRoundMetadata()).isPresent(); + assertThat(read.getPreparedRoundMetadata().get().getPreparedRound()).isEqualTo(1); + assertThat(read.getPreparedRoundMetadata().get().getPreparedBlockHash()) + .isEqualTo(PREPARED_HASH); + assertThat(read.encoded()).isEqualTo(encoded); + } + + @Test + public void aRoundChangeWithASealReadsBackIdentical() { + final FalconSeal seal = new FalconSeal(4, Bytes.fromHexString("0xdeadbeef")); + final RoundChangePayload p = + new RoundChangePayload(ROUND_ID, Optional.empty(), Optional.of(seal)); + final Bytes encoded = p.encoded(); + + // it is longer than the old form, and CONTAINS it as a prefix of the content + assertThat(encoded.size()).isGreaterThan(oldBareForm().size()); + + final RoundChangePayload read = + RoundChangePayload.readFrom(new BytesValueRLPInput(encoded, false)); + assertThat(read.getFalconSeal()).isPresent(); + assertThat(read.getFalconSeal().get().getValidatorIndex()).isEqualTo(4); + assertThat(read.getFalconSeal().get().getSignature()) + .isEqualTo(Bytes.fromHexString("0xdeadbeef")); + assertThat(read.encoded()).isEqualTo(encoded); + } + + @Test + public void aSealedRoundChangeWithPreparedMetadataReadsBackIdentical() { + final FalconSeal seal = new FalconSeal(2, Bytes.fromHexString("0xfeedface")); + final RoundChangePayload p = + new RoundChangePayload( + ROUND_ID, Optional.of(new PreparedRoundMetadata(PREPARED_HASH, 2)), Optional.of(seal)); + final Bytes encoded = p.encoded(); + final RoundChangePayload read = + RoundChangePayload.readFrom(new BytesValueRLPInput(encoded, false)); + assertThat(read.getFalconSeal()).isPresent(); + assertThat(read.getPreparedRoundMetadata()).isPresent(); + assertThat(read.encoded()).isEqualTo(encoded); + } + + @Test + public void theGoldenBytesMatchTheSecondClient() { + // The SAME two hex strings are asserted by the second client (AereQbftPrepareSealWireProofTests, + // AurRcFaraSigiliu / AurRcCuSigiliu) for the same sequence/round/seal. If either implementation + // moves, one of the two tests goes red - the cross-implementation link. A round trip through our + // own encoder would only prove that we agree with ourselves. + assertThat(new RoundChangePayload(ROUND_ID, Optional.empty()).encoded().toUnprefixedHexString()) + .isEqualTo("c30703c0"); + assertThat( + new RoundChangePayload( + ROUND_ID, + Optional.empty(), + Optional.of(new FalconSeal(4, Bytes.fromHexString("0xdeadbeef")))) + .encoded() + .toUnprefixedHexString()) + .isEqualTo("ca0703c0c60484deadbeef"); + } + + @Test + public void aSealChangesTheSignatureHash() { + // If it did not change it, the author's ECDSA signature would not cover the seal, and anyone + // could paste a foreign index onto an otherwise valid round-change. + final RoundChangePayload without = new RoundChangePayload(ROUND_ID, Optional.empty()); + final RoundChangePayload with = + new RoundChangePayload( + ROUND_ID, + Optional.empty(), + Optional.of(new FalconSeal(4, Bytes.fromHexString("0xdeadbeef")))); + assertThat(with.hashForSignature()).isNotEqualTo(without.hashForSignature()); + } + + @Test + public void anElementAfterTheSealIsRefused() { + // A decoder that ignored it would re-encode without it: two different byte strings would + // authenticate to the same author. It is refused. + final BytesValueRLPOutput out = new BytesValueRLPOutput(); + out.startList(); + out.writeLongScalar(ROUND_ID.getSequenceNumber()); + out.writeIntScalar(ROUND_ID.getRoundNumber()); + out.startList(); + out.endList(); + out.startList(); + out.writeIntScalar(4); + out.writeBytes(Bytes.fromHexString("0xdeadbeef")); + out.endList(); + out.writeIntScalar(1); // the trailing element nothing accounts for + out.endList(); + + assertThatThrownBy( + () -> RoundChangePayload.readFrom(new BytesValueRLPInput(out.encoded(), false))) + .isInstanceOf(RLPException.class); + } + + @Test + public void aFourthElementThatIsNotASealIsRefused() { + final BytesValueRLPOutput out = new BytesValueRLPOutput(); + out.startList(); + out.writeLongScalar(ROUND_ID.getSequenceNumber()); + out.writeIntScalar(ROUND_ID.getRoundNumber()); + out.startList(); + out.endList(); + out.writeBytes(Bytes.fromHexString("0x01")); // not a [index, signature] list + out.endList(); + + assertThatThrownBy( + () -> RoundChangePayload.readFrom(new BytesValueRLPInput(out.encoded(), false))) + .isInstanceOf(RLPException.class); + } + + // ---- domain separation: the security part of the design -------------------------------------- + + private static final Bytes32 DIGEST = + Bytes32.fromHexString("0x000000000000000000000000000000000000000000000000000000000000002a"); + + @Test + public void theRoundChangeMessageIsNoneOfItsThreeSiblings() { + // If any two were the same, a seal given HONESTLY in one role could be replayed in the other: + // "move on" counted as a vote, an offer, or the other way around. + final Bytes32 rc = PqAnchor.roundChangeMessage(2800L, 100L, 3); + assertThat(rc).isNotEqualTo(PqAnchor.prepareMessage(2800L, 100L, 3, DIGEST)); + assertThat(rc).isNotEqualTo(PqAnchor.proposalMessage(2800L, 100L, 3, DIGEST)); + assertThat(rc).isNotEqualTo(PqAnchor.commitMessage(2800L, 100L, DIGEST)); + } + + @Test + public void theRoundChangeMessageDependsOnTargetRoundChainAndHeight() { + final Bytes32 base = PqAnchor.roundChangeMessage(2800L, 100L, 3); + assertThat(PqAnchor.roundChangeMessage(2800L, 100L, 4)).isNotEqualTo(base); + assertThat(PqAnchor.roundChangeMessage(2801L, 100L, 3)).isNotEqualTo(base); + assertThat(PqAnchor.roundChangeMessage(2800L, 101L, 3)).isNotEqualTo(base); + } + + @Test + public void aBareAndAPreparedRoundChangeMessageDiffer() { + // The metadata is in the preimage: "move on" and "move on and re-propose THIS block" are two + // different assertions, and their seals must not be interchangeable. + final Bytes32 bare = PqAnchor.roundChangeMessage(2800L, 100L, 3); + final Bytes32 prepared = PqAnchor.roundChangeMessage(2800L, 100L, 3, 1, DIGEST); + assertThat(prepared).isNotEqualTo(bare); + assertThat(PqAnchor.roundChangeMessage(2800L, 100L, 3, 2, DIGEST)).isNotEqualTo(prepared); + } + + @Test + public void theRoundChangeMessageRefusesImpossibleInputs() { + assertThatThrownBy(() -> PqAnchor.roundChangeMessage(2800L, -1L, 3)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> PqAnchor.roundChangeMessage(2800L, 100L, -1)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> PqAnchor.roundChangeMessage(2800L, 100L, 3, -1, DIGEST)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy( + () -> PqAnchor.roundChangeMessage(2800L, 100L, 3, 1, Bytes.fromHexString("0x00"))) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/statemachine/PqHybridExtrasNeverThrowTest.java b/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/statemachine/PqHybridExtrasNeverThrowTest.java new file mode 100755 index 000000000..2efccf985 --- /dev/null +++ b/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/statemachine/PqHybridExtrasNeverThrowTest.java @@ -0,0 +1,69 @@ +/* + * Copyright contributors to Hyperledger Besu. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.qbft.core.statemachine; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.hyperledger.besu.consensus.common.bft.HybridSealSupport; + +import org.apache.tuweni.bytes.Bytes32; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * D-334 (2026-09-03): a hybrid configuration the node cannot read must cost the node its SLH-DSA + * contribution, never its vote. The loader throws (the defect is real and named); the state + * machine's helper returns an empty list instead of letting that exception reach the QBFT event + * loop. Both halves are asserted, so the test cannot pass by the configuration being silently + * ignored. + */ +public class PqHybridExtrasNeverThrowTest { + + private static final String[] PROPS = { + HybridSealSupport.PROPERTY_SCHEDULE, + HybridSealSupport.PROPERTY_REGISTRY, + HybridSealSupport.PROPERTY_ATTACH_BLOCK + }; + + @BeforeEach + public void plantAnUnreadableRegistry() { + HybridSealSupport.resetForTesting(); + System.setProperty(HybridSealSupport.PROPERTY_SCHEDULE, "10:falcon-512+slh-dsa-sha2-128s"); + System.setProperty( + HybridSealSupport.PROPERTY_REGISTRY, "/nonexistent/aere-d334/hibrid-1.properties"); + } + + @AfterEach + public void cleanUp() { + for (final String p : PROPS) { + System.clearProperty(p); + } + HybridSealSupport.resetForTesting(); + } + + @Test + public void theLoaderNamesTheDefect() { + assertThatThrownBy(HybridSealSupport::instance) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("AERE-PQC-HYBRID-CONF-04"); + } + + @Test + public void theStateMachineHelperReturnsEmptyInsteadOfThrowing() { + assertThat(QbftRound.hybridExtrasOrEmpty(31L, Bytes32.ZERO)).isEmpty(); + } +} diff --git a/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/statemachine/PqLateSealSalvageTest.java b/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/statemachine/PqLateSealSalvageTest.java new file mode 100755 index 000000000..82586ae25 --- /dev/null +++ b/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/statemachine/PqLateSealSalvageTest.java @@ -0,0 +1,195 @@ +/* + * Copyright contributors to Besu. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.qbft.core.statemachine; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.hyperledger.besu.consensus.common.bft.ConsensusRoundIdentifier; +import org.hyperledger.besu.consensus.common.bft.FalconSeal; +import org.hyperledger.besu.consensus.common.bft.MessageTracker; +import org.hyperledger.besu.consensus.common.bft.PqSealCache; +import org.hyperledger.besu.consensus.common.bft.statemachine.FutureMessageBuffer; +import org.hyperledger.besu.consensus.qbft.core.QbftMessageFixture; +import org.hyperledger.besu.consensus.qbft.core.QbftReceivedMessageEventFixture; +import org.hyperledger.besu.consensus.qbft.core.messagedata.CommitMessageData; +import org.hyperledger.besu.consensus.qbft.core.messagedata.QbftV1; +import org.hyperledger.besu.consensus.qbft.core.messagewrappers.Commit; +import org.hyperledger.besu.consensus.qbft.core.types.QbftBlockCodec; +import org.hyperledger.besu.consensus.qbft.core.types.QbftBlockHeader; +import org.hyperledger.besu.consensus.qbft.core.types.QbftBlockchain; +import org.hyperledger.besu.consensus.qbft.core.types.QbftFinalState; +import org.hyperledger.besu.consensus.qbft.core.types.QbftGossiper; +import org.hyperledger.besu.consensus.qbft.core.types.QbftMessage; +import org.hyperledger.besu.datatypes.Address; +import org.hyperledger.besu.datatypes.Hash; + +import java.util.List; +import java.util.Optional; + +import com.google.common.collect.ImmutableList; +import org.apache.tuweni.bytes.Bytes; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +/** + * AERE D-227 (2026-08-14): a Commit that arrives AFTER its block was imported is discarded by the + * height gate in {@link QbftController#consumeMessage}, and before this patch its Falcon seal died + * with it. Measured on chain 2800: the block imports on the quorum-th Commit, the slowest + * validators' Commits consistently arrive after that moment, and their seals appeared in 3% and + * 14% of other proposers' certificates while appearing in 100% of their own. + * + *

Every claim here has its pair: the one case that salvages, and the five refusals around it. + * The refusals are not decoration - each one guards a real path (an older seal nobody can ask for + * again, a fork sibling's seal, a non-validator author, a seal-less commit, and the message itself + * staying dead). + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +public class PqLateSealSalvageTest { + + @Mock private QbftBlockchain blockChain; + @Mock private QbftFinalState qbftFinalState; + @Mock private QbftBlockHeightManagerFactory blockHeightManagerFactory; + @Mock private QbftBlockHeader chainHeadBlockHeader; + @Mock private BaseQbftBlockHeightManager blockHeightManager; + @Mock private Commit commit; + @Mock private CommitMessageData commitMessageData; + @Mock private MessageTracker messageTracker; + @Mock private FutureMessageBuffer futureMessageBuffer; + @Mock private QbftGossiper qbftGossiper; + @Mock private QbftBlockCodec blockEncoder; + + private static final long HEAD = 3L; + private static final Hash HEAD_HASH = Hash.hash(Bytes.fromHexString("0xaa")); + private static final Hash OTHER_HASH = Hash.hash(Bytes.fromHexString("0xbb")); + private final Address validator = Address.fromHexString("0x1"); + private final Address nonValidator = Address.fromHexString("0x2"); + private final FalconSeal seal = new FalconSeal(4, Bytes.fromHexString("0x29aabbcc")); + + private QbftController qbftController; + + @BeforeEach + public void setup() { + PqSealCache.instance().clear(); + when(blockChain.getChainHeadHeader()).thenReturn(chainHeadBlockHeader); + when(blockChain.getChainHeadBlockNumber()).thenReturn(HEAD); + when(blockHeightManagerFactory.create(any())).thenReturn(blockHeightManager); + when(qbftFinalState.getValidators()).thenReturn(ImmutableList.of(validator)); + when(chainHeadBlockHeader.getNumber()).thenReturn(HEAD); + when(chainHeadBlockHeader.getHash()).thenReturn(HEAD_HASH); + when(blockHeightManager.getParentBlockHeader()).thenReturn(chainHeadBlockHeader); + when(blockHeightManager.getChainHeight()).thenReturn(HEAD + 1); + when(qbftFinalState.isLocalNodeValidator()).thenReturn(true); + when(messageTracker.hasSeenMessage(any())).thenReturn(false); + qbftController = + new QbftController( + blockChain, + qbftFinalState, + blockHeightManagerFactory, + qbftGossiper, + messageTracker, + futureMessageBuffer, + blockEncoder); + qbftController.start(); + } + + @AfterEach + public void cleanup() { + // The cache is a singleton: a seal left behind would leak into unrelated tests and buy them + // an unearned green. + PqSealCache.instance().clear(); + } + + private void deliverCommit( + final long height, final Hash digest, final Address author, final Optional fs) { + when(commit.getAuthor()).thenReturn(author); + when(commit.getRoundIdentifier()).thenReturn(new ConsensusRoundIdentifier(height, 0)); + when(commit.getDigest()).thenReturn(digest); + when(commit.getFalconSeal()).thenReturn(fs); + when(commitMessageData.getCode()).thenReturn(QbftV1.COMMIT); + when(commitMessageData.decode()).thenReturn(commit); + qbftController.handleMessageEvent( + new QbftReceivedMessageEventFixture(new QbftMessageFixture(commitMessageData))); + } + + private List cached() { + return PqSealCache.instance().sealsFor(HEAD, HEAD_HASH); + } + + @Test + public void lateCommitSealForImportedHeadIsSalvaged() { + deliverCommit(HEAD, HEAD_HASH, validator, Optional.of(seal)); + assertThat(cached()).containsExactly(seal); + } + + @Test + public void salvagedMessageStillDies() { + // The pair of the test above, on the same delivery: only the seal survives. Resurrecting the + // message would reopen the very height gate the upstream code closed on purpose. + deliverCommit(HEAD, HEAD_HASH, validator, Optional.of(seal)); + verify(blockHeightManager, never()).handleCommitPayload(any()); + } + + @Test + public void sealOlderThanHeadIsNotSalvaged() { + // The proposer of block HEAD+1 carries a certificate over HEAD. A seal for HEAD-1 can never + // be asked for again; keeping it would only grow the cache. + deliverCommit(HEAD - 1, HEAD_HASH, validator, Optional.of(seal)); + assertThat(cached()).isEmpty(); + assertThat(PqSealCache.instance().entryCount()).isZero(); + } + + @Test + public void sealOverDifferentBlockAtHeadHeightIsNotSalvaged() { + // A losing round or a fork sibling: same height, different digest. Its seal is over a block + // hash the fleet did not import, so carrying it would fail verification anyway - refusing it + // here keeps the cache honest instead of relying on the later check. + deliverCommit(HEAD, OTHER_HASH, validator, Optional.of(seal)); + assertThat(cached()).isEmpty(); + } + + @Test + public void sealFromNonValidatorIsNotSalvaged() { + // Without this refusal any peer could write into the cache of every node it is connected to. + deliverCommit(HEAD, HEAD_HASH, nonValidator, Optional.of(seal)); + assertThat(cached()).isEmpty(); + } + + @Test + public void commitWithoutSealChangesNothing() { + deliverCommit(HEAD, HEAD_HASH, validator, Optional.empty()); + assertThat(cached()).isEmpty(); + assertThat(PqSealCache.instance().entryCount()).isZero(); + } + + @Test + public void currentHeightCommitIsUntouchedByTheSalvagePath() { + // CONTROL: a commit for the CURRENT height (head+1) must take the normal path - handled, + // not salvaged. If this fails, the patch moved the gate instead of adding a side-exit. + deliverCommit(HEAD + 1, HEAD_HASH, validator, Optional.of(seal)); + verify(blockHeightManager).handleCommitPayload(commit); + assertThat(cached()).isEmpty(); + } +} diff --git a/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/statemachine/PqSealsWithoutProposalTest.java b/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/statemachine/PqSealsWithoutProposalTest.java new file mode 100755 index 000000000..47a2256c4 --- /dev/null +++ b/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/statemachine/PqSealsWithoutProposalTest.java @@ -0,0 +1,168 @@ +/* + * Copyright contributors to Aere Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in + * compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is + * distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the License for the specific language governing permissions and limitations under the + * License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.qbft.core.statemachine; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Optional; +import org.apache.tuweni.bytes.Bytes; +import org.hyperledger.besu.consensus.common.bft.ConsensusRoundIdentifier; +import org.hyperledger.besu.consensus.common.bft.FalconSeal; +import org.hyperledger.besu.consensus.common.bft.PqSealCache; +import org.hyperledger.besu.consensus.common.bft.RoundTimer; +import org.hyperledger.besu.consensus.common.bft.SchemeSeal; +import org.hyperledger.besu.consensus.qbft.core.network.QbftMessageTransmitter; +import org.hyperledger.besu.consensus.qbft.core.payload.MessageFactory; +import org.hyperledger.besu.consensus.qbft.core.types.QbftBlockCreator; +import org.hyperledger.besu.consensus.qbft.core.types.QbftBlockHeader; +import org.hyperledger.besu.consensus.qbft.core.types.QbftBlockInterface; +import org.hyperledger.besu.consensus.qbft.core.types.QbftMinedBlockObserver; +import org.hyperledger.besu.consensus.qbft.core.types.QbftProtocolSchedule; +import org.hyperledger.besu.consensus.qbft.core.validation.MessageValidator; +import org.hyperledger.besu.consensus.qbft.core.validation.QbftBlockHeaderTestFixture; +import org.hyperledger.besu.cryptoservices.NodeKey; +import org.hyperledger.besu.cryptoservices.NodeKeyUtils; +import org.hyperledger.besu.datatypes.Address; +import org.hyperledger.besu.datatypes.Hash; +import org.hyperledger.besu.util.Subscribers; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +/** + * D-339 (2026-09-04): a Commit teaches this node the seals of its block even when this node holds NO + * proposal for that round. + * + *

The measured defect, on chain 2800 the same evening: anchor proposers kept refusing with "this + * node holds 1 valid eligible Falcon seal ... 0 rejected" - they were not rejecting seals, they had + * never heard them. The cache was fed only from a round that held the proposed block, so a node that + * missed the proposal of the parent's round collected the Commits and dropped their seals; it then + * could not build the next anchor's certificate, the round expired, and every 32nd height paid the + * round-change timeout (eleven of twelve anchors before this fix). + * + *

What is pinned here: with no proposal in the round, a Commit's Falcon seal AND its hybrid extra + * seals land in the cache under the digest the Commit names; and the negative control, a Commit + * carrying no seals, leaves the cache empty. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +public class PqSealsWithoutProposalTest { + + private final NodeKey nodeKey = NodeKeyUtils.generate(); + private final Address localAddress = Address.extract(nodeKey.getPublicKey()); + private final NodeKey peerKey = NodeKeyUtils.generate(); + private final ConsensusRoundIdentifier roundIdentifier = new ConsensusRoundIdentifier(7, 0); + private final Subscribers subscribers = Subscribers.create(); + private final Hash digest = Hash.hash(Bytes.fromHexString("0xdeadbeef")); + private final FalconSeal falcon = new FalconSeal(4, Bytes.fromHexString("0x29aabbcc")); + private final SchemeSeal slh = new SchemeSeal((byte) 0x02, 4, Bytes.fromHexString("0x0102030405")); + + private MessageFactory localFactory; + private MessageFactory peerFactory; + + @Mock private org.hyperledger.besu.consensus.qbft.core.types.QbftBlockCodec blockEncoder; + + @Mock private QbftProtocolSchedule protocolSchedule; + @Mock private QbftMessageTransmitter transmitter; + @Mock private MessageValidator messageValidator; + @Mock private RoundTimer roundTimer; + @Mock private QbftBlockCreator blockCreator; + @Mock private QbftBlockInterface blockInterface; + + private final QbftBlockHeader parentHeader = + new QbftBlockHeaderTestFixture().number(6).buildHeader(); + + @BeforeEach + public void setup() { + PqSealCache.instance().clear(); + localFactory = new MessageFactory(nodeKey, blockEncoder); + peerFactory = new MessageFactory(peerKey, blockEncoder); + when(messageValidator.validateCommit(org.mockito.ArgumentMatchers.any())).thenReturn(true); + } + + @AfterEach + public void clean() { + PqSealCache.instance().clear(); + } + + private QbftRound roundWithoutProposal() { + final RoundState roundState = new RoundState(roundIdentifier, 3, messageValidator); + assertThat(roundState.getProposedBlock()).isEmpty(); + return new QbftRound( + roundState, + blockCreator, + blockInterface, + protocolSchedule, + subscribers, + nodeKey, + localAddress, + localFactory, + transmitter, + roundTimer, + parentHeader); + } + + @Test + public void aCommitWithoutAProposalStillTeachesItsSeals() { + final QbftRound round = roundWithoutProposal(); + round.handleCommitMessage( + peerFactory.createCommit( + roundIdentifier, + digest, + peerKey.sign(org.apache.tuweni.bytes.Bytes32.ZERO), + Optional.of(falcon), + List.of(slh))); + + assertThat(PqSealCache.instance().sealsFor(roundIdentifier.getSequenceNumber(), digest)) + .containsExactly(falcon); + assertThat(PqSealCache.instance().extrasFor(roundIdentifier.getSequenceNumber(), digest)) + .containsExactly(slh); + } + + @Test + public void aCommitWithoutSealsLeavesTheCacheEmpty() { + // the negative control: the path must not write an entry just because a Commit arrived + final QbftRound round = roundWithoutProposal(); + round.handleCommitMessage( + peerFactory.createCommit(roundIdentifier, digest, peerKey.sign(org.apache.tuweni.bytes.Bytes32.ZERO))); + + assertThat(PqSealCache.instance().sealsFor(roundIdentifier.getSequenceNumber(), digest)) + .isEmpty(); + assertThat(PqSealCache.instance().extrasFor(roundIdentifier.getSequenceNumber(), digest)) + .isEmpty(); + assertThat(PqSealCache.instance().entryCount()).isZero(); + } + + @Test + public void sealsLandUnderTheDigestTheCommitNames() { + // a seal must never be filed under some other block's hash: the producer looks up by the + // parent's on-chain hash, and a mis-filed seal would be an invisible way to poison a lookup + final QbftRound round = roundWithoutProposal(); + round.handleCommitMessage( + peerFactory.createCommit( + roundIdentifier, digest, peerKey.sign(org.apache.tuweni.bytes.Bytes32.ZERO), Optional.of(falcon), List.of(slh))); + + final Hash altul = Hash.hash(Bytes.fromHexString("0xfeedface")); + assertThat(PqSealCache.instance().sealsFor(roundIdentifier.getSequenceNumber(), altul)).isEmpty(); + } +} diff --git a/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/CommitValidatorAnchorFormTest.java b/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/CommitValidatorAnchorFormTest.java new file mode 100755 index 000000000..4d055a04f --- /dev/null +++ b/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/CommitValidatorAnchorFormTest.java @@ -0,0 +1,216 @@ +/* + * Copyright contributors to the Aere Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in + * compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is + * distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the License for the specific language governing permissions and limitations under the + * License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.qbft.core.validation; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.hyperledger.besu.consensus.common.bft.ConsensusRoundIdentifier; +import org.hyperledger.besu.consensus.common.bft.FalconSeal; +import org.hyperledger.besu.consensus.common.bft.PqAnchorConfig; +import org.hyperledger.besu.consensus.common.bft.PqSignerRegistry; +import org.hyperledger.besu.consensus.common.bft.SealScheme; +import org.hyperledger.besu.consensus.common.bft.SealSchemes; +import org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer; +import org.hyperledger.besu.consensus.qbft.core.messagewrappers.Commit; +import org.hyperledger.besu.consensus.qbft.core.types.QbftBlockCodec; +import org.hyperledger.besu.crypto.SECPSignature; +import org.hyperledger.besu.crypto.SecureRandomProvider; +import org.hyperledger.besu.datatypes.Address; +import org.hyperledger.besu.datatypes.Hash; + +import java.security.SecureRandom; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.OptionalInt; + +import org.apache.tuweni.bytes.Bytes; +import org.apache.tuweni.bytes.Bytes32; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +/** + * AERE D-311 (2026-09-02). Found by the public testnet 28001 on its first day, with the options of + * fleet 2800: at {@code aere.pq.commitPq.forkBlock} every validator refused every commit ("post-quantum + * seal of index i does NOT verify over the commit digest") and the chain stopped. The emitter signs + * the ANCHOR form of the commit message as soon as the anchor is armed (on 2800 since 13,014,000); + * the verifier checked the ECDSA committed-seal digest. Two copies of one rule, and they drifted. The + * F95 rehearsal could not see it: its kit runs without the anchor armed. + * + *

What this proves, with real Falcon-512 signatures and nothing mocked on the cryptographic path: + * + *

    + *
  • anchor armed: a seal made exactly as the emitter makes it (over {@code + * PqAnchorProducer.commitSealMessage}) COUNTS through the production message; + *
  • CONTROL NEGATIV: the SAME seal fed to a validator that still verifies over the commit digest + * (the pre-D-311 behaviour, kept by the upstream-shaped constructors) is REFUZAT. This is the + * bug itself, reproduced; if this test ever goes green the fix has been undone somewhere else; + *
  • anchor armed, an emitter that kept signing the digest is REFUZAT by the fixed verifier; + *
  • anchor never armed: the message IS the commit digest, so old and new verifiers agree. + *
+ */ +@ExtendWith(MockitoExtension.class) +public class CommitValidatorAnchorFormTest { + private static final int VALIDATOR_COUNT = 3; + private static final long CHAIN_ID = 28001L; + private static final long HEIGHT = 5_000L; + private final ConsensusRoundIdentifier round = new ConsensusRoundIdentifier(HEIGHT, 0); + /** The ECDSA committed-seal digest (round-specific). */ + private final Hash commitDigest = Hash.fromHexStringLenient("0x1"); + /** The round-independent on-chain hash of the same block (round forced to 0). */ + private final Hash onchainHash = Hash.fromHexStringLenient("0x2"); + private QbftNodeList validators; + private @Mock QbftBlockCodec qbftBlockCodec; + private final SecureRandom random = SecureRandomProvider.createSecureRandom(); + private final Map bindings = new HashMap<>(); + private final Map cheiPublice = new HashMap<>(); + private final Map cheiPrivate = new HashMap<>(); + + private final PqSignerRegistry registry = + new PqSignerRegistry() { + @Override + public Address addressForIndexAtHistoric(final long blockNumber, final int validatorIndex) { + return bindings.get(validatorIndex); + } + + @Override + public Address addressForIndexAtOwnHead(final long blockNumber, final int validatorIndex) { + return bindings.get(validatorIndex); + } + + @Override + public boolean verifyAtHistoric( + final long blockNumber, + final int validatorIndex, + final Bytes message, + final Bytes signature) { + return verifyAtOwnHead(blockNumber, validatorIndex, message, signature); + } + + @Override + public boolean verifyAtOwnHead( + final long blockNumber, + final int validatorIndex, + final Bytes message, + final Bytes signature) { + final byte[] pk = cheiPublice.get(validatorIndex); + return pk != null + && SealSchemes.FALCON_512.verifyRaw(pk, message.toArray(), signature.toArray()); + } + }; + + @BeforeEach + public void setup() { + validators = QbftNodeList.createNodes(VALIDATOR_COUNT, qbftBlockCodec); + for (int i = 0; i < VALIDATOR_COUNT; i++) { + final SealScheme.GeneratedPair pereche = SealSchemes.FALCON_512.generate(random); + bindings.put(i, validators.getNode(i).getAddress()); + cheiPublice.put(i, pereche.publicRegistryForm()); + cheiPrivate.put(i, pereche.privateKey()); + } + } + + @AfterEach + public void forgetAnchorConfig() { + PqAnchorProducer.useConfigForTesting(null); + } + + private void armAnchorAt(final long anchorBlock) { + PqAnchorProducer.useConfigForTesting( + new PqAnchorConfig( + CHAIN_ID, anchorBlock, Map.of(anchorBlock, 0), OptionalInt.empty(), false)); + } + + /** Exactly what the emitter (QbftRound.pqSealMessageFor) signs at HEIGHT. */ + private Bytes32 emitterMessage() { + return PqAnchorProducer.commitSealMessage(HEIGHT, onchainHash.getBytes(), commitDigest); + } + + /** The fixed production shape: the verifier is handed the same message the emitter signs. */ + private CommitValidator verificatorReparat(final Bytes32 expectedPqSealMessage) { + return new CommitValidator( + validators.getNodeAddresses(), + round, + commitDigest, + commitDigest, + Hash.wrap(expectedPqSealMessage), + new PqCommitEnforcement(HEIGHT, registry)); + } + + /** The pre-D-311 shape: enforcement verifies over the commit digest, whatever the emitter signed. */ + private CommitValidator verificatorVechi() { + return new CommitValidator( + validators.getNodeAddresses(), + round, + commitDigest, + commitDigest, + new PqCommitEnforcement(HEIGHT, registry)); + } + + private Commit commitSealedOver(final int nod, final Bytes32 message) { + final SECPSignature ecdsa = + validators.getNode(nod).getNodeKey().sign(Bytes32.wrap(commitDigest.getBytes())); + final byte[] sig = + SealSchemes.FALCON_512.sign(cheiPrivate.get(nod), message.toArray()).orElseThrow(); + return validators + .getMessageFactory(nod) + .createCommit(round, commitDigest, ecdsa, Optional.of(new FalconSeal(nod, Bytes.wrap(sig)))); + } + + @Test + public void anchorArmedTheMessageIsTheAnchorFormNotTheDigest() { + armAnchorAt(HEIGHT); + assertThat(PqAnchorProducer.sealMessageIsAnchorForm(HEIGHT)).isTrue(); + assertThat(emitterMessage()).isNotEqualTo(Bytes32.wrap(commitDigest.getBytes())); + } + + @Test + public void anchorArmedSealMadeLikeTheEmitterCountsThroughTheFixedVerifier() { + armAnchorAt(HEIGHT); + final Bytes32 message = emitterMessage(); + assertThat(verificatorReparat(message).validate(commitSealedOver(0, message))).isTrue(); + } + + @Test + public void controlNegativTheOldVerifierRefusesTheVerySameSeal() { + // This is D-311 itself: the seal the emitter really produces, judged over the commit digest. + // On the testnet this line was the whole chain stopping at the fork height. + armAnchorAt(HEIGHT); + final Bytes32 message = emitterMessage(); + assertThat(verificatorVechi().validate(commitSealedOver(0, message))).isFalse(); + } + + @Test + public void anchorArmedAnEmitterStillSigningTheDigestIsRefused() { + armAnchorAt(HEIGHT); + final Bytes32 sealedOverDigest = Bytes32.wrap(commitDigest.getBytes()); + assertThat(verificatorReparat(emitterMessage()).validate(commitSealedOver(1, sealedOverDigest))) + .isFalse(); + } + + @Test + public void anchorNeverArmedTheMessageIsTheDigestAndBothVerifiersAgree() { + PqAnchorProducer.useConfigForTesting(PqAnchorConfig.never(CHAIN_ID)); + final Bytes32 message = emitterMessage(); + assertThat(message).isEqualTo(Bytes32.wrap(commitDigest.getBytes())); + assertThat(verificatorReparat(message).validate(commitSealedOver(2, message))).isTrue(); + assertThat(verificatorVechi().validate(commitSealedOver(2, message))).isTrue(); + } +} diff --git a/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/CommitValidatorPqEnforcementTest.java b/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/CommitValidatorPqEnforcementTest.java new file mode 100755 index 000000000..b3269194f --- /dev/null +++ b/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/CommitValidatorPqEnforcementTest.java @@ -0,0 +1,152 @@ +/* AERE full-PQ, the WIRING step: enforcement bound into CommitValidator itself. + * + * What each case proves: + * - disarmed (the old constructor) = upstream behaviour, untouched -- the baseline control; + * - armed + commit WITHOUT a PQ seal = the vote does NOT count; + * - armed + a REAL Falcon seal over the commit digest = the vote counts; + * - armed + another validator's seal (index bound to another address) = refused; + * - armed + the same message below the arming height = passes (the gate is the height itself). + * + * Mesajele sunt semnate ECDSA cu uneltele de amonte (QbftNodeList/MessageFactory), sigiliile + * sunt Falcon-512 REAL prin stratul de scheme; nimic mockuit pe drumul criptografic. */ +package org.hyperledger.besu.consensus.qbft.core.validation; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.hyperledger.besu.consensus.common.bft.ConsensusRoundIdentifier; +import org.hyperledger.besu.consensus.common.bft.FalconSeal; +import org.hyperledger.besu.consensus.common.bft.PqSignerRegistry; +import org.hyperledger.besu.consensus.common.bft.SealScheme; +import org.hyperledger.besu.consensus.common.bft.SealSchemes; +import org.hyperledger.besu.consensus.qbft.core.messagewrappers.Commit; +import org.hyperledger.besu.consensus.qbft.core.types.QbftBlockCodec; +import org.hyperledger.besu.crypto.SECPSignature; +import org.hyperledger.besu.crypto.SecureRandomProvider; +import org.hyperledger.besu.datatypes.Address; +import org.hyperledger.besu.datatypes.Hash; + +import java.security.SecureRandom; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +import org.apache.tuweni.bytes.Bytes; +import org.apache.tuweni.bytes.Bytes32; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +public class CommitValidatorPqEnforcementTest { + + private static final int VALIDATOR_COUNT = 3; + private static final long HEIGHT = 5_000L; + + private final ConsensusRoundIdentifier round = new ConsensusRoundIdentifier(HEIGHT, 0); + private final Hash expectedHash = Hash.fromHexStringLenient("0x1"); + private QbftNodeList validators; + private @Mock QbftBlockCodec qbftBlockCodec; + + private final SecureRandom random = SecureRandomProvider.createSecureRandom(); + private final Map bindings = new HashMap<>(); + private final Map cheiPublice = new HashMap<>(); + private final Map cheiPrivate = new HashMap<>(); + + /** Registru de test cu legaturi index->adresa si verificare prin schema REALA. */ + private final PqSignerRegistry registry = + new PqSignerRegistry() { + @Override + public Address addressForIndexAtHistoric(final long blockNumber, final int validatorIndex) { + return bindings.get(validatorIndex); + } + + @Override + public Address addressForIndexAtOwnHead(final long blockNumber, final int validatorIndex) { + return bindings.get(validatorIndex); + } + + @Override + public boolean verifyAtHistoric( + final long blockNumber, final int validatorIndex, final Bytes message, final Bytes signature) { + return verifyAtOwnHead(blockNumber, validatorIndex, message, signature); + } + + @Override + public boolean verifyAtOwnHead( + final long blockNumber, final int validatorIndex, final Bytes message, final Bytes signature) { + final byte[] pk = cheiPublice.get(validatorIndex); + return pk != null + && SealSchemes.FALCON_512.verifyRaw(pk, message.toArray(), signature.toArray()); + } + }; + + @BeforeEach + public void setup() { + validators = QbftNodeList.createNodes(VALIDATOR_COUNT, qbftBlockCodec); + for (int i = 0; i < VALIDATOR_COUNT; i++) { + final SealScheme.GeneratedPair pereche = SealSchemes.FALCON_512.generate(random); + bindings.put(i, validators.getNode(i).getAddress()); + cheiPublice.put(i, pereche.publicRegistryForm()); + cheiPrivate.put(i, pereche.privateKey()); + } + } + + private CommitValidator armat(final long armedFrom) { + return new CommitValidator( + validators.getNodeAddresses(), + round, + expectedHash, + expectedHash, + new PqCommitEnforcement(armedFrom, registry)); + } + + private Commit commitWithoutSeal(final int nod) { + final SECPSignature ecdsa = + validators.getNode(nod).getNodeKey().sign(Bytes32.wrap(expectedHash.getBytes())); + return validators.getMessageFactory(nod).createCommit(round, expectedHash, ecdsa); + } + + private Commit commitWithSeal(final int nodEcdsa, final int indexFalcon) { + final SECPSignature ecdsa = + validators.getNode(nodEcdsa).getNodeKey().sign(Bytes32.wrap(expectedHash.getBytes())); + final byte[] sig = + SealSchemes.FALCON_512 + .sign(cheiPrivate.get(indexFalcon), expectedHash.getBytes().toArray()) + .orElseThrow(); + return validators + .getMessageFactory(nodEcdsa) + .createCommit(round, expectedHash, ecdsa, Optional.of(new FalconSeal(indexFalcon, Bytes.wrap(sig)))); + } + + @Test + public void disarmedOldConstructorIsUpstreamBehaviour() { + final CommitValidator old = + new CommitValidator(validators.getNodeAddresses(), round, expectedHash, expectedHash); + assertThat(old.validate(commitWithoutSeal(0))).isTrue(); + } + + @Test + public void armedRejectsCommitWithoutPqSeal() { + assertThat(armat(HEIGHT).validate(commitWithoutSeal(0))).isFalse(); + } + + @Test + public void armedAcceptsCommitWithRealPqSeal() { + for (int i = 0; i < VALIDATOR_COUNT; i++) { + assertThat(armat(HEIGHT).validate(commitWithSeal(i, i))).isTrue(); + } + } + + @Test + public void armedRejectsSealOfAnotherValidator() { + // node 0's ECDSA message, index 1's Falcon seal: the author binding fails + assertThat(armat(HEIGHT).validate(commitWithSeal(0, 1))).isFalse(); + } + + @Test + public void belowArmingHeightSealIsNotRequired() { + assertThat(armat(HEIGHT + 1).validate(commitWithoutSeal(0))).isTrue(); + } +} diff --git a/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/MessageValidatorAnchorFormPlumbingTest.java b/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/MessageValidatorAnchorFormPlumbingTest.java new file mode 100755 index 000000000..a5a78d58e --- /dev/null +++ b/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/MessageValidatorAnchorFormPlumbingTest.java @@ -0,0 +1,109 @@ +/* + * Copyright contributors to the Aere Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in + * compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is + * distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the License for the specific language governing permissions and limitations under the + * License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.qbft.core.validation; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.hyperledger.besu.consensus.common.bft.ConsensusRoundIdentifier; +import org.hyperledger.besu.consensus.common.bft.PqAnchor; +import org.hyperledger.besu.consensus.common.bft.PqAnchorConfig; +import org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer; +import org.hyperledger.besu.consensus.qbft.core.types.QbftBlock; +import org.hyperledger.besu.consensus.qbft.core.types.QbftBlockInterface; +import org.hyperledger.besu.datatypes.Address; +import org.hyperledger.besu.datatypes.Hash; + +import java.util.List; +import java.util.Map; +import java.util.OptionalInt; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +/** + * AERE D-311, the PRODUCTION wiring. {@link CommitValidatorAnchorFormTest} proves the verifier accepts + * the emitter's seal when it is handed the emitter's message; this proves that {@link + * MessageValidator.SubsequentMessageValidator}, the only production caller, hands it exactly that + * message: the anchor form over the ROUND-INDEPENDENT on-chain hash (round forced to 0) once the + * anchor is armed, and the plain committed-seal digest before, computed through the one shared helper + * and touching the block interface for round 0 only when the anchor form needs it. + */ +@ExtendWith(MockitoExtension.class) +public class MessageValidatorAnchorFormPlumbingTest { + private static final long CHAIN_ID = 28001L; + private static final long HEIGHT = 7_000L; + private static final int ROUND = 2; + private final ConsensusRoundIdentifier round = new ConsensusRoundIdentifier(HEIGHT, ROUND); + private final Hash proposalHash = Hash.fromHexStringLenient("0x11"); + private final Hash commitHash = Hash.fromHexStringLenient("0x22"); + private final Hash onchainHash = Hash.fromHexStringLenient("0x33"); + + private @Mock QbftBlockInterface blockInterface; + private @Mock QbftBlock proposalBlock; + private @Mock QbftBlock commitBlock; + private @Mock QbftBlock roundZeroBlock; + + @BeforeEach + public void setup() { + when(proposalBlock.getHash()).thenReturn(proposalHash); + when(blockInterface.replaceRoundForCommitBlock(proposalBlock, ROUND)).thenReturn(commitBlock); + when(commitBlock.getHash()).thenReturn(commitHash); + } + + @AfterEach + public void forgetAnchorConfig() { + PqAnchorProducer.useConfigForTesting(null); + } + + private MessageValidator.SubsequentMessageValidator build() { + return new MessageValidator.SubsequentMessageValidator( + List.of(Address.fromHexString("0x1")), round, proposalBlock, blockInterface); + } + + @Test + public void anchorArmedTheVerifierIsHandedTheAnchorFormOverTheRoundZeroHash() { + PqAnchorProducer.useConfigForTesting( + new PqAnchorConfig(CHAIN_ID, HEIGHT, Map.of(HEIGHT, 0), OptionalInt.empty(), false)); + when(blockInterface.replaceRoundForCommitBlock(proposalBlock, 0)).thenReturn(roundZeroBlock); + when(roundZeroBlock.getHash()).thenReturn(onchainHash); + + final Hash handed = build().commitValidatorForTesting().expectedPqSealMessageForTesting(); + + assertThat(handed) + .isEqualTo(Hash.wrap(PqAnchor.commitMessage(CHAIN_ID, HEIGHT, onchainHash.getBytes()))); + assertThat(handed).isNotEqualTo(commitHash); + verify(blockInterface).replaceRoundForCommitBlock(proposalBlock, 0); + } + + @Test + public void anchorNeverArmedTheVerifierIsHandedTheCommitDigestAndRoundZeroIsNeverAsked() { + PqAnchorProducer.useConfigForTesting(PqAnchorConfig.never(CHAIN_ID)); + + final Hash handed = build().commitValidatorForTesting().expectedPqSealMessageForTesting(); + + assertThat(handed).isEqualTo(commitHash); + verify(blockInterface, never()).replaceRoundForCommitBlock(any(), eq(0)); + } +} diff --git a/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqCommitEnforcementTest.java b/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqCommitEnforcementTest.java new file mode 100755 index 000000000..8124b0c1f --- /dev/null +++ b/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqCommitEnforcementTest.java @@ -0,0 +1,159 @@ +/* AERE full-PQ, step 1, the core's proofs. The key case is integration with REAL + * cryptography: a true Falcon seal over the commit digest passes, one with a flipped bit + * does not, and a seal bound to a DIFFERENT author cannot vouch for anyone else's vote. + * The registry is a test double implementing the whole interface (the compiler is the + * control: without a height no answer is possible -- the D2 inheritance). */ +package org.hyperledger.besu.consensus.qbft.core.validation; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.security.SecureRandom; +import java.util.Map; +import java.util.Optional; + +import org.apache.tuweni.bytes.Bytes; +import org.hyperledger.besu.consensus.common.bft.FalconSeal; +import org.hyperledger.besu.consensus.common.bft.PqSignerRegistry; +import org.hyperledger.besu.consensus.common.bft.SealScheme; +import org.hyperledger.besu.consensus.common.bft.SealSchemes; +import org.hyperledger.besu.crypto.SecureRandomProvider; +import org.hyperledger.besu.datatypes.Address; +import org.hyperledger.besu.datatypes.Hash; +import org.junit.jupiter.api.Test; + +class PqCommitEnforcementTest { + + private static final long H_ARMARE = 1_000_000L; + private static final Address VALIDATOR_0 = Address.fromHexString("0x" + "aa".repeat(20)); + private static final Address VALIDATOR_1 = Address.fromHexString("0x" + "bb".repeat(20)); + private static final Hash DIGEST = Hash.hash(Bytes.of(7, 7, 7)); + + private final SecureRandom random = SecureRandomProvider.createSecureRandom(); + + /** Registru de test: legaturi index->adresa programate + verificare prin schema REALA. */ + private static final class RegistruDeTest implements PqSignerRegistry { + final Map bindings; + final Map keys; + + RegistruDeTest(final Map bindings, final Map keys) { + this.bindings = bindings; + this.keys = keys; + } + + @Override + public Address addressForIndexAtHistoric(final long blockNumber, final int validatorIndex) { + return bindings.get(validatorIndex); + } + + @Override + public Address addressForIndexAtOwnHead(final long blockNumber, final int validatorIndex) { + return bindings.get(validatorIndex); + } + + @Override + public boolean verifyAtHistoric( + final long blockNumber, final int validatorIndex, final Bytes message, final Bytes signature) { + return verifyAtOwnHead(blockNumber, validatorIndex, message, signature); + } + + @Override + public boolean verifyAtOwnHead( + final long blockNumber, final int validatorIndex, final Bytes message, final Bytes signature) { + final byte[] pk = keys.get(validatorIndex); + if (pk == null) { + return false; + } + return SealSchemes.FALCON_512.verifyRaw(pk, message.toArray(), signature.toArray()); + } + } + + private record World(PqCommitEnforcement enforcement, FalconSeal sigiliuValid0) {} + + /** O lume cu 2 validatori cu chei Falcon reale; sigiliul validatorului 0 peste DIGEST. */ + private World world() { + final SealScheme.GeneratedPair k0 = SealSchemes.FALCON_512.generate(random); + final SealScheme.GeneratedPair k1 = SealSchemes.FALCON_512.generate(random); + final byte[] sig0 = SealSchemes.FALCON_512.sign(k0.privateKey(), DIGEST.getBytes().toArray()).orElseThrow(); + final RegistruDeTest reg = + new RegistruDeTest( + Map.of(0, VALIDATOR_0, 1, VALIDATOR_1), + Map.of(0, k0.publicRegistryForm(), 1, k1.publicRegistryForm())); + return new World(new PqCommitEnforcement(H_ARMARE, reg), new FalconSeal(0, Bytes.wrap(sig0))); + } + + // ------------------------------------------------------------------ sub si la granita + + @Test + void belowArmingHeightEverythingCountsEvenWithoutSeal() { + final World l = world(); + assertThat(l.enforcement().refusal(H_ARMARE - 1, VALIDATOR_0, DIGEST, Optional.empty())).isEmpty(); + assertThat(l.enforcement().armedAt(H_ARMARE - 1)).isFalse(); + } + + @Test + void disarmedNeverEnforces() { + final World l = world(); + final PqCommitEnforcement dezarmat = + new PqCommitEnforcement(PqCommitEnforcement.DISARMED, new RegistruDeTest(Map.of(), Map.of())); + assertThat(dezarmat.refusal(Long.MAX_VALUE - 1, VALIDATOR_0, DIGEST, Optional.empty())).isEmpty(); + assertThat(l).isNotNull(); + } + + @Test + void atTheExactArmingHeightEnforcementBites() { + final World l = world(); + final Optional refusal = l.enforcement().refusal(H_ARMARE, VALIDATOR_0, DIGEST, Optional.empty()); + assertThat(refusal).isPresent(); + assertThat(refusal.get()).contains("NO post-quantum seal").contains(String.valueOf(H_ARMARE)); + } + + // ------------------------------------------------------------------ drumul fericit + negative + + @Test + void validRealSealCounts() { + final World l = world(); + assertThat(l.enforcement().refusal(H_ARMARE, VALIDATOR_0, DIGEST, Optional.of(l.sigiliuValid0()))) + .isEmpty(); + } + + @Test + void sealBoundToAnotherAuthorCannotVouch() { + final World l = world(); + // sigiliul indexului 0 (legat de VALIDATOR_0) pe un mesaj SEMNAT de VALIDATOR_1 + final Optional refusal = + l.enforcement().refusal(H_ARMARE, VALIDATOR_1, DIGEST, Optional.of(l.sigiliuValid0())); + assertThat(refusal).isPresent(); + assertThat(refusal.get()).contains("someone else"); + } + + @Test + void corruptedSignatureIsRefusedWithTheIndexNamed() { + final World l = world(); + final byte[] stricat = l.sigiliuValid0().getSignature().toArray().clone(); + stricat[stricat.length / 2] ^= 0x01; + final Optional refusal = + l.enforcement() + .refusal(H_ARMARE, VALIDATOR_0, DIGEST, Optional.of(new FalconSeal(0, Bytes.wrap(stricat)))); + assertThat(refusal).isPresent(); + assertThat(refusal.get()).contains("does NOT verify").contains("index 0"); + } + + @Test + void unknownIndexIsRefusedNotTrusted() { + final World l = world(); + final Optional refusal = + l.enforcement() + .refusal(H_ARMARE, VALIDATOR_0, DIGEST, Optional.of(new FalconSeal(7, l.sigiliuValid0().getSignature()))); + assertThat(refusal).isPresent(); // a missing binding (null) is never "fine" + } + + @Test + void sealOverADifferentDigestDoesNotCount() { + final World l = world(); + final Hash altDigest = Hash.hash(Bytes.of(9, 9, 9)); + final Optional refusal = + l.enforcement().refusal(H_ARMARE, VALIDATOR_0, altDigest, Optional.of(l.sigiliuValid0())); + assertThat(refusal).isPresent(); + assertThat(refusal.get()).contains("does NOT verify"); + } +} diff --git a/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqCommitPlumbingTest.java b/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqCommitPlumbingTest.java new file mode 100755 index 000000000..c697e69dc --- /dev/null +++ b/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqCommitPlumbingTest.java @@ -0,0 +1,108 @@ +/* AERE full-PQ, the PLUMBING: CommitValidator's production constructor (the 4-argument one, + * the only one MessageValidator calls) self-installs from the system property. The + * differential is the proof itself: same message, same constructor, the only difference is + * the property -- disarmed passes, armed below the height passes, armed at the height + * refuses. And the loud refusal: a broken value throws AERE-PQC-COMMIT-CONF-01 at + * construction, because a mistyped comma must not silently boot the node disarmed. The + * property is cleaned in finally so it cannot poison other classes in the same JVM (the + * order-dependent-green lesson). */ +package org.hyperledger.besu.consensus.qbft.core.validation; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.hyperledger.besu.consensus.common.bft.ConsensusRoundIdentifier; +import org.hyperledger.besu.consensus.qbft.core.messagewrappers.Commit; +import org.hyperledger.besu.consensus.qbft.core.types.QbftBlockCodec; +import org.hyperledger.besu.crypto.SECPSignature; +import org.hyperledger.besu.datatypes.Hash; + +import org.apache.tuweni.bytes.Bytes32; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +public class PqCommitPlumbingTest { + + private static final long HEIGHT = 7_777L; + + private final ConsensusRoundIdentifier round = new ConsensusRoundIdentifier(HEIGHT, 0); + private final Hash expectedHash = Hash.fromHexStringLenient("0x1"); + private QbftNodeList validators; + private @Mock QbftBlockCodec qbftBlockCodec; + + @BeforeEach + public void setup() { + validators = QbftNodeList.createNodes(3, qbftBlockCodec); + } + + @AfterEach + public void curataProprietatea() { + System.clearProperty(PqCommitEnforcement.PROPERTY_FORK_BLOCK); + } + + private CommitValidator validatorDeProductie() { + // constructorul de 4 argumente: EXACT ce cheama MessageValidator.SubsequentMessageValidator + return new CommitValidator(validators.getNodeAddresses(), round, expectedHash, expectedHash); + } + + private Commit commitWithoutSeal() { + final SECPSignature ecdsa = + validators.getNode(0).getNodeKey().sign(Bytes32.wrap(expectedHash.getBytes())); + return validators.getMessageFactory(0).createCommit(round, expectedHash, ecdsa); + } + + @Test + public void withoutThePropertyProductionConstructorIsUpstream() { + System.clearProperty(PqCommitEnforcement.PROPERTY_FORK_BLOCK); + assertThat(validatorDeProductie().validate(commitWithoutSeal())).isTrue(); + } + + @Test + public void withThePropertyAtHeightUnsealedCommitStopsCounting() { + try { + System.setProperty(PqCommitEnforcement.PROPERTY_FORK_BLOCK, String.valueOf(HEIGHT)); + assertThat(validatorDeProductie().validate(commitWithoutSeal())).isFalse(); + } finally { + System.clearProperty(PqCommitEnforcement.PROPERTY_FORK_BLOCK); + } + } + + @Test + public void withThePropertyAboveHeightNothingChangesYet() { + try { + System.setProperty(PqCommitEnforcement.PROPERTY_FORK_BLOCK, String.valueOf(HEIGHT + 1)); + assertThat(validatorDeProductie().validate(commitWithoutSeal())).isTrue(); + } finally { + System.clearProperty(PqCommitEnforcement.PROPERTY_FORK_BLOCK); + } + } + + @Test + public void brokenValueRefusesLoudlyInsteadOfDisarming() { + try { + System.setProperty(PqCommitEnforcement.PROPERTY_FORK_BLOCK, "14,050,000"); + assertThatThrownBy(this::validatorDeProductie) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("AERE-PQC-COMMIT-CONF-01"); + } finally { + System.clearProperty(PqCommitEnforcement.PROPERTY_FORK_BLOCK); + } + } + + @Test + public void negativeValueRefusesLoudly() { + try { + System.setProperty(PqCommitEnforcement.PROPERTY_FORK_BLOCK, "-1"); + assertThatThrownBy(this::validatorDeProductie) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("AERE-PQC-COMMIT-CONF-01"); + } finally { + System.clearProperty(PqCommitEnforcement.PROPERTY_FORK_BLOCK); + } + } +} diff --git a/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqHybridEnforcementTest.java b/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqHybridEnforcementTest.java new file mode 100755 index 000000000..cf8c683f0 --- /dev/null +++ b/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqHybridEnforcementTest.java @@ -0,0 +1,312 @@ +/* AERE HYBRID, step 2: HYBRID CERTIFICATE enforcement at the commit quorum. + * + * Everything measured here uses REAL cryptography (Falcon-512 + SLH-DSA-128s generated on + * every run, TEST keys) and a REAL hybrid registry built from properties, i.e. exactly the + * production loading path. The validators' REAL keys are not generated here and are not + * generated at all without the founder's ceremony and signature. + * + * The thesis it proves: above the height where the schedule requires two families, a vote + * carrying only one does NOT count. A half hybrid is worth the weakest family present, + * so a missing scheme refuses, it does not degrade. */ +package org.hyperledger.besu.consensus.qbft.core.validation; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.hyperledger.besu.consensus.common.bft.FalconSeal; +import org.hyperledger.besu.consensus.common.bft.HybridSignerRegistry; +import org.hyperledger.besu.consensus.common.bft.PqSchemeSchedule; +import org.hyperledger.besu.consensus.common.bft.PqSignerRegistry; +import org.hyperledger.besu.consensus.common.bft.SchemeSeal; +import org.hyperledger.besu.consensus.common.bft.SealScheme; +import org.hyperledger.besu.consensus.common.bft.SealSchemes; +import org.hyperledger.besu.crypto.SecureRandomProvider; +import org.hyperledger.besu.datatypes.Address; +import org.hyperledger.besu.datatypes.Hash; + +import java.security.SecureRandom; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Properties; + +import org.apache.tuweni.bytes.Bytes; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +public class PqHybridEnforcementTest { + + private static final long H_ARMARE = 1_000L; + private static final long H_HIBRID = 2_000L; + private static final Address VALIDATOR_0 = Address.fromHexString("0x" + "aa".repeat(20)); + private static final Address VALIDATOR_1 = Address.fromHexString("0x" + "bb".repeat(20)); + private static final Hash DIGEST = Hash.hash(Bytes.of(4, 2)); + + private final SecureRandom random = SecureRandomProvider.createSecureRandom(); + + private SealScheme.GeneratedPair falcon0; + private SealScheme.GeneratedPair slh0; + private HybridSignerRegistry registry; + private PqSchemeSchedule orar; + + /** Registrul Falcon vechi: leaga indexul 0 de VALIDATOR_0 si verifica cu schema reala. */ + private PqSignerRegistry registruFalcon() { + return new PqSignerRegistry() { + @Override + public Address addressForIndexAtHistoric(final long blockNumber, final int validatorIndex) { + return addressForIndexAtOwnHead(blockNumber, validatorIndex); + } + + @Override + public Address addressForIndexAtOwnHead(final long blockNumber, final int validatorIndex) { + return validatorIndex == 0 ? VALIDATOR_0 : null; + } + + @Override + public boolean verifyAtHistoric( + final long b, final int i, final Bytes message, final Bytes signature) { + return verifyAtOwnHead(b, i, message, signature); + } + + @Override + public boolean verifyAtOwnHead( + final long b, final int i, final Bytes message, final Bytes signature) { + return i == 0 + && SealSchemes.FALCON_512.verifyRaw( + falcon0.publicRegistryForm(), message.toArray(), signature.toArray()); + } + }; + } + + @BeforeEach + public void setup() { + falcon0 = SealSchemes.FALCON_512.generate(random); + slh0 = SealSchemes.SLH_DSA_128S.generate(random); + + final Properties p = new Properties(); + p.setProperty("formatVersion", HybridSignerRegistry.FORMAT_VERSION); + p.setProperty("chainId", "2800"); + p.setProperty("count", "1"); + p.setProperty("0.addr", VALIDATOR_0.toHexString()); + p.setProperty( + "0.key." + SealSchemes.FALCON_512.id(), + Bytes.wrap(falcon0.publicRegistryForm()).toHexString()); + p.setProperty( + "0.key." + SealSchemes.SLH_DSA_128S.id(), + Bytes.wrap(slh0.publicRegistryForm()).toHexString()); + registry = HybridSignerRegistry.fromProperties(p, "proba"); + + // pana la H_HIBRID doar Falcon; de acolo AMANDOUA familiile + orar = + PqSchemeSchedule.parse( + H_ARMARE + + ":" + + SealSchemes.FALCON_512.id() + + "," + + H_HIBRID + + ":" + + SealSchemes.FALCON_512.id() + + "+" + + SealSchemes.SLH_DSA_128S.id()); + } + + private PqCommitEnforcement hibrid() { + // every height counts as an anchor parent here, so the refusals below keep their meaning + return new PqCommitEnforcement(H_ARMARE, registruFalcon(), orar, registry, h -> true); + } + + private FalconSeal sigiliuFalcon(final int index) { + return new FalconSeal( + index, + Bytes.wrap( + SealSchemes.FALCON_512 + .sign(falcon0.privateKey(), DIGEST.getBytes().toArray()) + .orElseThrow())); + } + + private SchemeSeal sigiliuSlh(final int index, final Hash peste) { + return new SchemeSeal( + SealSchemes.SLH_DSA_128S.wireId(), + index, + Bytes.wrap( + SealSchemes.SLH_DSA_128S + .sign(slh0.privateKey(), peste.getBytes().toArray()) + .orElseThrow())); + } + + // ============================================================ configuratia + + @Test + public void halfAHybridConfigurationRefusesAtConstruction() { + assertThatThrownBy( + () -> new PqCommitEnforcement(H_ARMARE, registruFalcon(), orar, null)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("AERE-PQC-COMMIT-CONF-03"); + assertThatThrownBy( + () -> new PqCommitEnforcement(H_ARMARE, registruFalcon(), null, registry)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("AERE-PQC-COMMIT-CONF-03"); + } + + @Test + public void falconOnlyModeIgnoresExtrasEntirely() { + final PqCommitEnforcement doarFalcon = new PqCommitEnforcement(H_ARMARE, registruFalcon()); + assertThat( + doarFalcon.refusal( + H_HIBRID, VALIDATOR_0, DIGEST, Optional.of(sigiliuFalcon(0)), List.of())) + .isEmpty(); + } + + // ============================================================ sub si peste treapta hibrida + + @Test + public void belowTheHybridStepFalconAloneIsEnough() { + assertThat( + hibrid() + .refusal( + H_HIBRID - 1, VALIDATOR_0, DIGEST, Optional.of(sigiliuFalcon(0)), List.of())) + .isEmpty(); + } + + @Test + public void atTheHybridStepFalconAloneNoLongerCounts() { + final Optional refusal = + hibrid() + .refusal(H_HIBRID, VALIDATOR_0, DIGEST, Optional.of(sigiliuFalcon(0)), List.of()); + assertThat(refusal).isPresent(); + assertThat(refusal.get()).contains("carries no").contains(SealSchemes.SLH_DSA_128S.id()); + } + + @Test + public void aFullHybridCertificateCounts() { + assertThat( + hibrid() + .refusal( + H_HIBRID, + VALIDATOR_0, + DIGEST, + Optional.of(sigiliuFalcon(0)), + List.of(sigiliuSlh(0, DIGEST)))) + .isEmpty(); + } + + // ============================================================ controalele negative + + @Test + public void aHybridSealOverAnotherDigestDoesNotCount() { + final Hash altul = Hash.hash(Bytes.of(9, 9)); + final Optional refusal = + hibrid() + .refusal( + H_HIBRID, + VALIDATOR_0, + DIGEST, + Optional.of(sigiliuFalcon(0)), + List.of(sigiliuSlh(0, altul))); + assertThat(refusal).isPresent(); + assertThat(refusal.get()).contains("does NOT verify"); + } + + @Test + public void aHybridSealForAnotherIndexIsRefused() { + final Optional refusal = + hibrid() + .refusal( + H_HIBRID, + VALIDATOR_0, + DIGEST, + Optional.of(sigiliuFalcon(0)), + List.of(sigiliuSlh(1, DIGEST))); + assertThat(refusal).isPresent(); + assertThat(refusal.get()).contains("one commit, one signer"); + } + + @Test + public void aCorruptedHybridSealIsRefusedWithTheSchemeNamed() { + final SchemeSeal bun = sigiliuSlh(0, DIGEST); + final byte[] stricat = bun.getSignature().toArray().clone(); + stricat[stricat.length / 3] ^= 0x01; + final Optional refusal = + hibrid() + .refusal( + H_HIBRID, + VALIDATOR_0, + DIGEST, + Optional.of(sigiliuFalcon(0)), + List.of( + new SchemeSeal( + SealSchemes.SLH_DSA_128S.wireId(), 0, Bytes.wrap(stricat)))); + assertThat(refusal).isPresent(); + assertThat(refusal.get()) + .contains("does NOT verify") + .contains(SealSchemes.SLH_DSA_128S.id()); + } + + @Test + public void anIndexNotBoundToTheAuthorInTheHybridRegistryIsRefused() { + // the Falcon registry binds index 0 to VALIDATOR_0; we ask for the verdict as if the + // author were VALIDATOR_1: the Falcon path refuses first, so the hybrid is never touched + final Optional refusal = + hibrid() + .refusal( + H_HIBRID, + VALIDATOR_1, + DIGEST, + Optional.of(sigiliuFalcon(0)), + List.of(sigiliuSlh(0, DIGEST))); + assertThat(refusal).isPresent(); + assertThat(refusal.get()).contains("someone else"); + } + + @Test + public void theTwoFamiliesAreIndependentAndTheProofSaysSo() { + // the control that gives the hybrid its meaning: a Falcon signature does not pass as + // SLH-DSA and vice versa. If it did, "hybrid" would be the same leg twice. + final byte[] message = DIGEST.getBytes().toArray(); + final byte[] sigF = + SealSchemes.FALCON_512.sign(falcon0.privateKey(), message).orElseThrow(); + final byte[] sigS = + SealSchemes.SLH_DSA_128S.sign(slh0.privateKey(), message).orElseThrow(); + assertThat(SealSchemes.SLH_DSA_128S.verifyRaw(slh0.publicRegistryForm(), message, sigF)) + .isFalse(); + assertThat(SealSchemes.FALCON_512.verifyRaw(falcon0.publicRegistryForm(), message, sigS)) + .isFalse(); + // and an SLH-DSA seal presented under the Falcon label cannot enter the hybrid slot, + // because lookup there goes by scheme label + final Optional refusal = + hibrid() + .refusal( + H_HIBRID, + VALIDATOR_0, + DIGEST, + Optional.of(sigiliuFalcon(0)), + List.of(new SchemeSeal(SealSchemes.FALCON_512.wireId(), 0, Bytes.wrap(sigS)))); + assertThat(refusal).isPresent(); + assertThat(refusal.get()).contains("carries no"); + } + + @Test + public void theRegistryItselfHoldsBothFamiliesForTheSameValidator() { + assertThat(registry.publicKey(0, SealSchemes.FALCON_512.id())).isPresent(); + assertThat(registry.publicKey(0, SealSchemes.SLH_DSA_128S.id())).isPresent(); + assertThat(registry.coverage(SealSchemes.SLH_DSA_128S.id())).isEqualTo(1); + assertThat(registry.address(0)).isPresent(); + assertThat(Map.of()).isEmpty(); + } + + /** D-329 (2026-09-03): off the anchor parents the extra seals are neither demanded nor emitted. */ + @Test + void offTheAnchorParentsACommitWithoutExtrasStillCounts() { + final PqCommitEnforcement pe = + new PqCommitEnforcement(H_ARMARE, registruFalcon(), orar, registry, h -> false); + final FalconSeal falcon = sigiliuFalcon(0); + assertThat(pe.refusal(H_HIBRID, VALIDATOR_0, DIGEST, java.util.Optional.of(falcon), java.util.List.of())) + .describedAs("no extras demanded where the successor is not an anchor") + .isEmpty(); + final PqCommitEnforcement peOn = + new PqCommitEnforcement(H_ARMARE, registruFalcon(), orar, registry, h -> true); + assertThat(peOn.refusal(H_HIBRID, VALIDATOR_0, DIGEST, java.util.Optional.of(falcon), java.util.List.of())) + .describedAs("the same commit IS refused where the successor is an anchor") + .isPresent(); + } +} diff --git a/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqMessageAgilityTest.java b/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqMessageAgilityTest.java new file mode 100755 index 000000000..cb51fed29 --- /dev/null +++ b/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqMessageAgilityTest.java @@ -0,0 +1,198 @@ +/* + * Copyright contributors to Besu. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.qbft.core.validation; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.hyperledger.besu.consensus.common.bft.FalconSeal; +import org.hyperledger.besu.consensus.common.bft.PqAnchor; +import org.hyperledger.besu.consensus.common.bft.PqSignerRegistry; +import org.hyperledger.besu.consensus.common.bft.SealScheme; +import org.hyperledger.besu.consensus.common.bft.SealSchemes; +import org.hyperledger.besu.consensus.qbft.core.payload.PreparedRoundMetadata; +import org.hyperledger.besu.crypto.SecureRandomProvider; +import org.hyperledger.besu.datatypes.Address; +import org.hyperledger.besu.datatypes.Hash; + +import java.security.SecureRandom; +import java.util.Map; +import java.util.Optional; + +import org.apache.tuweni.bytes.Bytes; +import org.apache.tuweni.bytes.Bytes32; +import org.junit.jupiter.api.Test; + +/** + * CRYPTOGRAPHIC AGILITY of the PROPOSAL and ROUND-CHANGE enforcements: the same consensus code, + * UNTOUCHED, accepts a seal made with a DIFFERENT post-quantum scheme. + * + *

The question and the method are {@link PqPrepareAgilityTest}'s, asked of the two surfaces + * built after it (PROPOSAL on 2026-08-30, ROUND-CHANGE on 2026-08-31): both enforcements receive + * the registry through their constructor and name no scheme, so the Falcon nail must sit only in + * the production wiring ({@code PqSignerRegistry.falconSealSupport()}), never in the enforcement + * class. If any of these tests fails, a change of maths would mean opening consensus code rather + * than adding a binding - a far more expensive finding than the test. + * + *

ONE class for both surfaces on purpose: the twin-class finding (D-293, consolidated + * 2026-08-31) measured what per-surface copies of the same idea cost - one gets a repair, the + * other silently does not. The shared harness here is the fix applied in advance. + * + *

WHAT THIS DOES NOT PROVE: that the fleet can run this way today. The production wiring stays + * Falcon-only, recorded as such in the findings register (D-285). + */ +class PqMessageAgilityTest { + + private static final long H_ARMARE = 1_000L; + private static final int ROUND = 2; + private static final long CHAIN_ID = 2800L; + private static final Address VALIDATOR_0 = Address.fromHexString("0x" + "cc".repeat(20)); + private static final Hash DIGEST = Hash.hash(Bytes.of(9, 9, 9)); + + private final SecureRandom random = SecureRandomProvider.createSecureRandom(); + + /** A registry that verifies under A GIVEN SCHEME, whichever it is. Nothing Falcon inside. */ + private static final class RegistryPerScheme implements PqSignerRegistry { + private final SealScheme scheme; + private final Map bindings; + private final Map keys; + + RegistryPerScheme( + final SealScheme scheme, final Map bindings, final Map keys) { + this.scheme = scheme; + this.bindings = bindings; + this.keys = keys; + } + + @Override + public Address addressForIndexAtHistoric(final long blockNumber, final int validatorIndex) { + return bindings.get(validatorIndex); + } + + @Override + public Address addressForIndexAtOwnHead(final long blockNumber, final int validatorIndex) { + return bindings.get(validatorIndex); + } + + @Override + public boolean verifyAtHistoric( + final long blockNumber, final int validatorIndex, final Bytes message, final Bytes signature) { + return verifyAtOwnHead(blockNumber, validatorIndex, message, signature); + } + + @Override + public boolean verifyAtOwnHead( + final long blockNumber, final int validatorIndex, final Bytes message, final Bytes signature) { + final byte[] pk = keys.get(validatorIndex); + return pk != null && scheme.verifyRaw(pk, message.toArray(), signature.toArray()); + } + } + + private RegistryPerScheme registryFor(final SealScheme scheme, final SealScheme.GeneratedPair k) { + return new RegistryPerScheme(scheme, Map.of(0, VALIDATOR_0), Map.of(0, k.publicRegistryForm())); + } + + /** A PROPOSAL signed with the given scheme, enforced over a registry on that same scheme. */ + private boolean proposalPassesUnder(final SealScheme scheme) { + final SealScheme.GeneratedPair k = scheme.generate(random); + final Bytes32 message = PqAnchor.proposalMessage(CHAIN_ID, H_ARMARE, ROUND, DIGEST.getBytes()); + final byte[] sig = scheme.sign(k.privateKey(), message.toArray()).orElseThrow(); + final PqProposalEnforcement enforcement = + new PqProposalEnforcement(H_ARMARE, registryFor(scheme, k), CHAIN_ID); + return enforcement + .refusal(H_ARMARE, ROUND, VALIDATOR_0, DIGEST, Optional.of(new FalconSeal(0, Bytes.wrap(sig)))) + .isEmpty(); + } + + /** + * A ROUND-CHANGE signed with the given scheme, enforced over a registry on that same scheme - + * bare or claiming a prepared block, because the two preimages differ and both must stay + * scheme-free. + */ + private boolean roundChangePassesUnder(final SealScheme scheme, final boolean withPrepared) { + final SealScheme.GeneratedPair k = scheme.generate(random); + final Bytes32 message = + withPrepared + ? PqAnchor.roundChangeMessage(CHAIN_ID, H_ARMARE, ROUND, 1, DIGEST.getBytes()) + : PqAnchor.roundChangeMessage(CHAIN_ID, H_ARMARE, ROUND); + final byte[] sig = scheme.sign(k.privateKey(), message.toArray()).orElseThrow(); + final PqRoundChangeEnforcement enforcement = + new PqRoundChangeEnforcement(H_ARMARE, registryFor(scheme, k), CHAIN_ID); + final Optional prm = + withPrepared ? Optional.of(new PreparedRoundMetadata(DIGEST, 1)) : Optional.empty(); + return enforcement + .refusal(H_ARMARE, ROUND, VALIDATOR_0, prm, Optional.of(new FalconSeal(0, Bytes.wrap(sig)))) + .isEmpty(); + } + + @Test + void aProposalSignedWithFALCONPasses() { + // THE WITNESS. Without it, a "passes" for SLH-DSA would not say whether the enforcement + // verifies anything at all. + assertThat(proposalPassesUnder(SealSchemes.FALCON_512)).isTrue(); + } + + @Test + void aProposalSignedWithSLHDSAPassesTHESAMEWay() { + // The same enforcement class, the same message, THE SAME consensus code - different maths. + assertThat(proposalPassesUnder(SealSchemes.SLH_DSA_128S)).isTrue(); + } + + @Test + void aRoundChangeSignedWithFALCONPasses() { + assertThat(roundChangePassesUnder(SealSchemes.FALCON_512, false)).isTrue(); + assertThat(roundChangePassesUnder(SealSchemes.FALCON_512, true)).isTrue(); + } + + @Test + void aRoundChangeSignedWithSLHDSAPassesTHESAMEWay() { + assertThat(roundChangePassesUnder(SealSchemes.SLH_DSA_128S, false)).isTrue(); + assertThat(roundChangePassesUnder(SealSchemes.SLH_DSA_128S, true)).isTrue(); + } + + @Test + void neitherEnforcementNAMESASchemeAtAll() { + // The control that makes "passes" mean something: a Falcon seal against an SLH-DSA registry + // must be REFUSED on both surfaces. Otherwise "passes" could just mean "does not verify". + final SealScheme.GeneratedPair falcon = SealSchemes.FALCON_512.generate(random); + final SealScheme.GeneratedPair slh = SealSchemes.SLH_DSA_128S.generate(random); + final RegistryPerScheme slhRegistry = registryFor(SealSchemes.SLH_DSA_128S, slh); + + final Bytes32 propMsg = PqAnchor.proposalMessage(CHAIN_ID, H_ARMARE, ROUND, DIGEST.getBytes()); + final byte[] propSigFalcon = + SealSchemes.FALCON_512.sign(falcon.privateKey(), propMsg.toArray()).orElseThrow(); + assertThat( + new PqProposalEnforcement(H_ARMARE, slhRegistry, CHAIN_ID) + .refusal( + H_ARMARE, + ROUND, + VALIDATOR_0, + DIGEST, + Optional.of(new FalconSeal(0, Bytes.wrap(propSigFalcon))))) + .isPresent(); + + final Bytes32 rcMsg = PqAnchor.roundChangeMessage(CHAIN_ID, H_ARMARE, ROUND); + final byte[] rcSigFalcon = + SealSchemes.FALCON_512.sign(falcon.privateKey(), rcMsg.toArray()).orElseThrow(); + assertThat( + new PqRoundChangeEnforcement(H_ARMARE, slhRegistry, CHAIN_ID) + .refusal( + H_ARMARE, + ROUND, + VALIDATOR_0, + Optional.empty(), + Optional.of(new FalconSeal(0, Bytes.wrap(rcSigFalcon))))) + .isPresent(); + } +} diff --git a/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqPrepareAgilityTest.java b/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqPrepareAgilityTest.java new file mode 100755 index 000000000..ce7fb371a --- /dev/null +++ b/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqPrepareAgilityTest.java @@ -0,0 +1,180 @@ +/* + * Copyright contributors to Besu. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.qbft.core.validation; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.hyperledger.besu.consensus.common.bft.FalconSeal; +import org.hyperledger.besu.consensus.common.bft.PqAnchor; +import org.hyperledger.besu.consensus.common.bft.PqSignerRegistry; +import org.hyperledger.besu.consensus.common.bft.SealScheme; +import org.hyperledger.besu.consensus.common.bft.SealSchemes; +import org.hyperledger.besu.crypto.SecureRandomProvider; +import org.hyperledger.besu.datatypes.Address; +import org.hyperledger.besu.datatypes.Hash; + +import java.security.SecureRandom; +import java.util.Map; +import java.util.Optional; + +import org.apache.tuweni.bytes.Bytes; +import org.apache.tuweni.bytes.Bytes32; +import org.junit.jupiter.api.Test; + +/** + * CRYPTOGRAPHIC AGILITY of the PREPARE enforcement: the same consensus code, UNTOUCHED, accepts a + * seal made with a DIFFERENT post-quantum scheme. + * + *

WHY THIS EXISTS - it is a question asked of my own work from the night of the 28th to the + * 29th. The anchor has been scheme-agile since 2026-08-28 (finding D-266): the hybrid registry + * holds keys PER SCHEME and dispatches through {@code SealSchemes.byId}. The new surface, PREPARE, + * reads at first sight as nailed to Falcon: the production wiring goes through + * {@code PqSignerRegistry.falconSealSupport()}, and that lands in {@code verifyWithKey}, which + * names {@code SealSchemes.FALCON_512} literally. The question that matters is not "is the wiring + * agile?" - plainly it is not - but where exactly the nail is: in the enforcement class, or only + * in the wiring? + * + *

This test answers by measurement. {@link PqPrepareEnforcement} receives the registry through + * its constructor and names no scheme at all; so if it is given a registry that verifies under + * SLH-DSA, a PREPARE signed with SLH-DSA must pass without touching one line of consensus + * code. If it passes, the nail is only in the wiring and comes out with a new binding rather + * than a rewrite. If it does not pass, the enforcement itself has to be opened up - and that would + * be a far more expensive finding. + * + *

SLH-DSA is the very second scheme the founder chose on 7 August for the hybrid certificate, + * and it is already live as a precompile on the chain from block 9,189,161. It is not a scheme + * invented for this test. + * + *

WHAT THIS DOES NOT PROVE: it does not say the fleet can run this way today. The production + * wiring stays Falcon-only, and that is written as such in the findings register. What is measured + * here is only where the limit sits. + */ +class PqPrepareAgilityTest { + + private static final long H_ARMARE = 1_000L; + private static final int ROUND = 2; + private static final long CHAIN_ID = 2800L; + private static final Address VALIDATOR_0 = Address.fromHexString("0x" + "cc".repeat(20)); + private static final Hash DIGEST = Hash.hash(Bytes.of(9, 9, 9)); + + private final SecureRandom random = SecureRandomProvider.createSecureRandom(); + + /** A registry that verifies under A GIVEN SCHEME, whichever it is. Nothing Falcon inside. */ + private static final class RegistryPerScheme implements PqSignerRegistry { + private final SealScheme scheme; + private final Map bindings; + private final Map keys; + + RegistryPerScheme( + final SealScheme scheme, final Map bindings, final Map keys) { + this.scheme = scheme; + this.bindings = bindings; + this.keys = keys; + } + + @Override + public Address addressForIndexAtHistoric(final long blockNumber, final int validatorIndex) { + return bindings.get(validatorIndex); + } + + @Override + public Address addressForIndexAtOwnHead(final long blockNumber, final int validatorIndex) { + return bindings.get(validatorIndex); + } + + @Override + public boolean verifyAtHistoric( + final long blockNumber, final int validatorIndex, final Bytes message, final Bytes signature) { + return verifyAtOwnHead(blockNumber, validatorIndex, message, signature); + } + + @Override + public boolean verifyAtOwnHead( + final long blockNumber, final int validatorIndex, final Bytes message, final Bytes signature) { + final byte[] pk = keys.get(validatorIndex); + return pk != null && scheme.verifyRaw(pk, message.toArray(), signature.toArray()); + } + } + + private Bytes32 message() { + return PqAnchor.prepareMessage(CHAIN_ID, H_ARMARE, ROUND, DIGEST.getBytes()); + } + + /** A PREPARE signed with the given scheme, enforced over a registry on that same scheme. */ + private boolean passesUnder(final SealScheme scheme) { + final SealScheme.GeneratedPair k = scheme.generate(random); + final byte[] sig = scheme.sign(k.privateKey(), message().toArray()).orElseThrow(); + final PqPrepareEnforcement enforcement = + new PqPrepareEnforcement( + H_ARMARE, + new RegistryPerScheme( + scheme, Map.of(0, VALIDATOR_0), Map.of(0, k.publicRegistryForm())), + CHAIN_ID); + final Optional refusal = + enforcement.refusal( + H_ARMARE, ROUND, VALIDATOR_0, DIGEST, Optional.of(new FalconSeal(0, Bytes.wrap(sig)))); + return refusal.isEmpty(); + } + + @Test + void aPrepareSignedWithFALCONPasses() { + // THE WITNESS. Without it, a "passes" for SLH-DSA would not say whether the enforcement + // verifies anything at all. + assertThat(passesUnder(SealSchemes.FALCON_512)).isTrue(); + } + + @Test + void aPrepareSignedWithSLHDSAPassesTHESAMEWay() { + // The same enforcement class, the same message, THE SAME consensus code - different maths. + assertThat(passesUnder(SealSchemes.SLH_DSA_128S)).isTrue(); + } + + @Test + void theEnforcementNAMESNoSchemeAtAll() { + // The control that makes the test above mean something: if the registry verifies under SLH-DSA + // but the seal was made with Falcon, it must be REFUSED. Otherwise "passes" could just mean + // "does not verify". + final SealScheme.GeneratedPair falcon = SealSchemes.FALCON_512.generate(random); + final SealScheme.GeneratedPair slh = SealSchemes.SLH_DSA_128S.generate(random); + final byte[] sigFalcon = + SealSchemes.FALCON_512.sign(falcon.privateKey(), message().toArray()).orElseThrow(); + + final PqPrepareEnforcement enforcement = + new PqPrepareEnforcement( + H_ARMARE, + new RegistryPerScheme( + SealSchemes.SLH_DSA_128S, + Map.of(0, VALIDATOR_0), + Map.of(0, slh.publicRegistryForm())), + CHAIN_ID); + final Optional refusal = + enforcement.refusal( + H_ARMARE, + ROUND, + VALIDATOR_0, + DIGEST, + Optional.of(new FalconSeal(0, Bytes.wrap(sigFalcon)))); + assertThat(refusal).isPresent(); + } + + @Test + void theTwoSchemesReallyAreDIFFERENT() { + // The second control of the method: if the two schemes happened to be the same implementation, + // the agility test would be a tautology. Their identities and key lengths must differ. + assertThat(SealSchemes.FALCON_512.id()).isNotEqualTo(SealSchemes.SLH_DSA_128S.id()); + assertThat(SealSchemes.FALCON_512.publicKeyLength()) + .isNotEqualTo(SealSchemes.SLH_DSA_128S.publicKeyLength()); + } +} diff --git a/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqPrepareEnforcementTest.java b/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqPrepareEnforcementTest.java new file mode 100755 index 000000000..620f0713c --- /dev/null +++ b/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqPrepareEnforcementTest.java @@ -0,0 +1,266 @@ +/* + * Copyright contributors to Besu. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.qbft.core.validation; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.hyperledger.besu.consensus.common.bft.FalconSeal; +import org.hyperledger.besu.consensus.common.bft.PqAnchor; +import org.hyperledger.besu.consensus.common.bft.PqSignerRegistry; +import org.hyperledger.besu.consensus.common.bft.SealScheme; +import org.hyperledger.besu.consensus.common.bft.SealSchemes; +import org.hyperledger.besu.crypto.SecureRandomProvider; +import org.hyperledger.besu.datatypes.Address; +import org.hyperledger.besu.datatypes.Hash; + +import java.security.SecureRandom; +import java.util.Map; +import java.util.Optional; + +import org.apache.tuweni.bytes.Bytes; +import org.apache.tuweni.bytes.Bytes32; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +/** + * PREPARE ENFORCEMENT, step 4. The structure follows PqCommitEnforcementTest deliberately. + * + *

What is proven here, each item closing one way of being wrong: + * + *

    + *
  1. below the arming height NOTHING changes - the condition for the binary to sit on the fleet; + *
  2. above it, a PREPARE without a seal does NOT count; + *
  3. a seal from ANOTHER validator does not vouch for this author; + *
  4. a seal over a DIFFERENT MESSAGE does not pass - in particular one over the COMMIT message, + * which is exactly the attack that domain separation closes; + *
  5. a seal from a DIFFERENT ROUND does not pass; + *
  6. a mistyped configuration REFUSES, it does not disarm. + *
+ * + *

The keys are REAL Falcon keys, generated in-process, and verification goes through the real + * scheme. A test with fake signatures would prove that we can compare strings, not that the + * enforcement enforces. + */ +class PqPrepareEnforcementTest { + + private static final long H_ARMARE = 1_000_000L; + private static final int ROUND = 3; + private static final Address VALIDATOR_0 = Address.fromHexString("0x" + "aa".repeat(20)); + private static final Address VALIDATOR_1 = Address.fromHexString("0x" + "bb".repeat(20)); + private static final Hash DIGEST = Hash.hash(Bytes.of(7, 7, 7)); + private static final long CHAIN_ID = 2800L; + + private final SecureRandom random = SecureRandomProvider.createSecureRandom(); + + @AfterEach + void curata() { + System.clearProperty(PqPrepareEnforcement.PROPERTY_FORK_BLOCK); + } + + /** Registru de test: legaturi index->adresa programate + verificare prin schema REALA. */ + private static final class RegistruDeTest implements PqSignerRegistry { + final Map bindings; + final Map keys; + + RegistruDeTest(final Map bindings, final Map keys) { + this.bindings = bindings; + this.keys = keys; + } + + @Override + public Address addressForIndexAtHistoric(final long blockNumber, final int validatorIndex) { + return bindings.get(validatorIndex); + } + + @Override + public Address addressForIndexAtOwnHead(final long blockNumber, final int validatorIndex) { + return bindings.get(validatorIndex); + } + + @Override + public boolean verifyAtHistoric( + final long blockNumber, final int validatorIndex, final Bytes message, final Bytes signature) { + return verifyAtOwnHead(blockNumber, validatorIndex, message, signature); + } + + @Override + public boolean verifyAtOwnHead( + final long blockNumber, final int validatorIndex, final Bytes message, final Bytes signature) { + final byte[] pk = keys.get(validatorIndex); + if (pk == null) { + return false; + } + return SealSchemes.FALCON_512.verifyRaw(pk, message.toArray(), signature.toArray()); + } + } + + private record World( + PqPrepareEnforcement enforcement, + FalconSeal valid0, + SealScheme.GeneratedPair k0, + SealScheme.GeneratedPair k1) {} + + private Bytes32 mesajPrepare(final long h, final int round) { + return PqAnchor.prepareMessage(CHAIN_ID, h, round, DIGEST.getBytes()); + } + + /** Two validators with real Falcon keys; validator 0's seal over the PREPARE message at H_ARMARE. */ + private World world() { + final SealScheme.GeneratedPair k0 = SealSchemes.FALCON_512.generate(random); + final SealScheme.GeneratedPair k1 = SealSchemes.FALCON_512.generate(random); + final byte[] sig0 = + SealSchemes.FALCON_512 + .sign(k0.privateKey(), mesajPrepare(H_ARMARE, ROUND).toArray()) + .orElseThrow(); + final RegistruDeTest reg = + new RegistruDeTest( + Map.of(0, VALIDATOR_0, 1, VALIDATOR_1), + Map.of(0, k0.publicRegistryForm(), 1, k1.publicRegistryForm())); + return new World( + new PqPrepareEnforcement(H_ARMARE, reg, CHAIN_ID), new FalconSeal(0, Bytes.wrap(sig0)), k0, k1); + } + + // --------------------------------------------------------------------------------------------- + // 1. BELOW the arming height NOTHING changes. The condition for the binary to sit on the fleet. + // --------------------------------------------------------------------------------------------- + @Test + void belowTheArmingHeightAnUnsealedPrepareDOESCount() { + final World l = world(); + assertThat(l.enforcement().armedAt(H_ARMARE - 1)).isFalse(); + assertThat(l.enforcement().refusal(H_ARMARE - 1, ROUND, VALIDATOR_0, DIGEST, Optional.empty())) + .isEmpty(); + } + + // --------------------------------------------------------------------------------------------- + // 2. ABOVE it, a PREPARE without a seal does not count. + // --------------------------------------------------------------------------------------------- + @Test + void aboveTheHeightAnUnsealedPrepareDoesNOTCount() { + final World l = world(); + final Optional refusal = + l.enforcement().refusal(H_ARMARE, ROUND, VALIDATOR_0, DIGEST, Optional.empty()); + assertThat(refusal).isPresent(); + assertThat(refusal.get()).contains("carries NO post-quantum seal"); + } + + // --------------------------------------------------------------------------------------------- + // 3. A GOOD seal from the author passes. + // --------------------------------------------------------------------------------------------- + @Test + void aGoodSealFromTheAuthorPasses() { + final World l = world(); + assertThat(l.enforcement().refusal(H_ARMARE, ROUND, VALIDATOR_0, DIGEST, Optional.of(l.valid0()))) + .isEmpty(); + } + + // --------------------------------------------------------------------------------------------- + // 4. The same seal, a different author: it does not vouch for somebody else. + // --------------------------------------------------------------------------------------------- + @Test + void aSealDoesNotVouchForAnotherAuthor() { + final World l = world(); + final Optional refusal = + l.enforcement().refusal(H_ARMARE, ROUND, VALIDATOR_1, DIGEST, Optional.of(l.valid0())); + assertThat(refusal).isPresent(); + assertThat(refusal.get()).contains("cannot vouch for someone else's vote"); + } + + // --------------------------------------------------------------------------------------------- + // 5. DOMAIN SEPARATION, and this is the security test of the whole step: a seal given HONESTLY + // over the COMMIT message must not pass as a PREPARE seal. + // --------------------------------------------------------------------------------------------- + @Test + void aSealOverTheCOMMITMessageDoesNotPassAsPREPARE() { + final World l = world(); + final Bytes32 mesajCommit = PqAnchor.commitMessage(CHAIN_ID, H_ARMARE, DIGEST.getBytes()); + final byte[] sigCommit = + SealSchemes.FALCON_512.sign(l.k0().privateKey(), mesajCommit.toArray()).orElseThrow(); + + final Optional refusal = + l.enforcement() + .refusal( + H_ARMARE, ROUND, VALIDATOR_0, DIGEST, Optional.of(new FalconSeal(0, Bytes.wrap(sigCommit)))); + assertThat(refusal).isPresent(); + assertThat(refusal.get()).contains("does NOT verify over the prepare message"); + } + + // --------------------------------------------------------------------------------------------- + // 6. The ROUND is in the preimage: a seal from a failed round does not justify another one. + // --------------------------------------------------------------------------------------------- + @Test + void aSealFromAnotherROUNDDoesNotPass() { + final World l = world(); + final byte[] sigAltaRunda = + SealSchemes.FALCON_512 + .sign(l.k0().privateKey(), mesajPrepare(H_ARMARE, ROUND + 1).toArray()) + .orElseThrow(); + final Optional refusal = + l.enforcement() + .refusal( + H_ARMARE, ROUND, VALIDATOR_0, DIGEST, Optional.of(new FalconSeal(0, Bytes.wrap(sigAltaRunda)))); + assertThat(refusal).isPresent(); + assertThat(refusal.get()).contains("does NOT verify over the prepare message"); + } + + // --------------------------------------------------------------------------------------------- + // 7. An index the registry binds to nobody. + // --------------------------------------------------------------------------------------------- + @Test + void anUNBOUNDIndexDoesNotPass() { + final World l = world(); + final Optional refusal = + l.enforcement() + .refusal(H_ARMARE, ROUND, VALIDATOR_0, DIGEST, Optional.of(new FalconSeal(99, l.valid0().getSignature()))); + assertThat(refusal).isPresent(); + assertThat(refusal.get()).contains("is bound to null"); + } + + // --------------------------------------------------------------------------------------------- + // 8. CONFIGURATION: absent = disarmed; a mistyped value = REFUSAL, never a silent disarming. + // --------------------------------------------------------------------------------------------- + @Test + void withoutThePropertyTheEnforcementIsNULL() { + assertThat(PqPrepareEnforcement.fromSystemConfig()).isNull(); + } + + @Test + void oValoareBunaArmeaza() { + System.setProperty(PqPrepareEnforcement.PROPERTY_FORK_BLOCK, "1234567"); + final PqPrepareEnforcement e = PqPrepareEnforcement.fromSystemConfig(); + assertThat(e).isNotNull(); + assertThat(e.armedAt(1_234_566L)).isFalse(); + assertThat(e.armedAt(1_234_567L)).isTrue(); + } + + @Test + void aMISTYPEDValueRefusesLoudly() { + for (final String bad : new String[] {"nu-e-numar", "1_234_567", "-1", "1e6"}) { + System.setProperty(PqPrepareEnforcement.PROPERTY_FORK_BLOCK, bad); + assertThatThrownBy(PqPrepareEnforcement::fromSystemConfig) + .as("valoarea '%s'", bad) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("AERE-PQC-PREPARE-ENF-01"); + } + } + + @Test + void theENFORCEMENTGateIsNotTheEMISSIONGate() { + // Doua proprietati distincte: se poate EMITE luni de zile fara sa se IMPUNA nimic. Daca ar fi + // una singura, primul nod care incepe sa emita ar incepe si sa refuze, si aia e o zi de flag. + assertThat(PqPrepareEnforcement.PROPERTY_FORK_BLOCK) + .isNotEqualTo("aere.pq.preparePq.attachBlock"); + } +} diff --git a/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqProposalEnforcementTest.java b/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqProposalEnforcementTest.java new file mode 100755 index 000000000..c4853f624 --- /dev/null +++ b/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqProposalEnforcementTest.java @@ -0,0 +1,297 @@ +/* + * Copyright contributors to Besu. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.qbft.core.validation; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.hyperledger.besu.consensus.common.bft.FalconSeal; +import org.hyperledger.besu.consensus.common.bft.PqAnchor; +import org.hyperledger.besu.consensus.common.bft.PqSignerRegistry; +import org.hyperledger.besu.consensus.common.bft.SealScheme; +import org.hyperledger.besu.consensus.common.bft.SealSchemes; +import org.hyperledger.besu.crypto.SecureRandomProvider; +import org.hyperledger.besu.datatypes.Address; +import org.hyperledger.besu.datatypes.Hash; + +import java.security.SecureRandom; +import java.util.Map; +import java.util.Optional; + +import org.apache.tuweni.bytes.Bytes; +import org.apache.tuweni.bytes.Bytes32; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +/** + * PROPOSAL ENFORCEMENT. The structure follows PqPrepareEnforcementTest deliberately - the same + * eight ways of being wrong, each with the test that closes it. + * + *

    + *
  1. below the arming height NOTHING changes - the condition for the binary to sit on the fleet; + *
  2. above it, a PROPOSAL without a seal is refused; + *
  3. a seal from ANOTHER validator does not vouch for this proposer; + *
  4. a seal over a DIFFERENT MESSAGE does not pass - tested against BOTH the PREPARE and the + * COMMIT messages, because a proposal seal that passed under either domain would let an + * honest proposal seal be replayed as a vote, or an honest vote seal be replayed as a + * proposal; + *
  5. a seal from a DIFFERENT ROUND does not pass; + *
  6. an index the registry binds to nobody does not pass; + *
  7. a mistyped configuration REFUSES, it does not disarm; + *
  8. the enforcement gate and the emission gate are two different switches. + *
+ * + *

The keys are REAL Falcon keys, generated in-process, and verification goes through the real + * scheme. A test with fake signatures would prove that we can compare strings, not that the + * enforcement enforces. + */ +class PqProposalEnforcementTest { + + private static final long ARMED_FROM = 1_000_000L; + private static final int ROUND = 3; + private static final Address PROPOSER_0 = Address.fromHexString("0x" + "aa".repeat(20)); + private static final Address VALIDATOR_1 = Address.fromHexString("0x" + "bb".repeat(20)); + private static final Hash DIGEST = Hash.hash(Bytes.of(8, 8, 8)); + private static final long CHAIN_ID = 2800L; + + private final SecureRandom random = SecureRandomProvider.createSecureRandom(); + + @AfterEach + void clearProperty() { + System.clearProperty(PqProposalEnforcement.PROPERTY_FORK_BLOCK); + } + + /** Test registry: programmed index-to-address bindings + verification through the REAL scheme. */ + private static final class TestRegistry implements PqSignerRegistry { + final Map bindings; + final Map keys; + + TestRegistry(final Map bindings, final Map keys) { + this.bindings = bindings; + this.keys = keys; + } + + @Override + public Address addressForIndexAtHistoric(final long blockNumber, final int validatorIndex) { + return bindings.get(validatorIndex); + } + + @Override + public Address addressForIndexAtOwnHead(final long blockNumber, final int validatorIndex) { + return bindings.get(validatorIndex); + } + + @Override + public boolean verifyAtHistoric( + final long blockNumber, final int validatorIndex, final Bytes message, final Bytes signature) { + return verifyAtOwnHead(blockNumber, validatorIndex, message, signature); + } + + @Override + public boolean verifyAtOwnHead( + final long blockNumber, final int validatorIndex, final Bytes message, final Bytes signature) { + final byte[] pk = keys.get(validatorIndex); + if (pk == null) { + return false; + } + return SealSchemes.FALCON_512.verifyRaw(pk, message.toArray(), signature.toArray()); + } + } + + private record World( + PqProposalEnforcement enforcement, + FalconSeal valid0, + SealScheme.GeneratedPair k0, + SealScheme.GeneratedPair k1) {} + + private Bytes32 proposalMessage(final long h, final int round) { + return PqAnchor.proposalMessage(CHAIN_ID, h, round, DIGEST.getBytes()); + } + + /** Two validators with real Falcon keys; validator 0's seal over the PROPOSAL message. */ + private World world() { + final SealScheme.GeneratedPair k0 = SealSchemes.FALCON_512.generate(random); + final SealScheme.GeneratedPair k1 = SealSchemes.FALCON_512.generate(random); + final byte[] sig0 = + SealSchemes.FALCON_512 + .sign(k0.privateKey(), proposalMessage(ARMED_FROM, ROUND).toArray()) + .orElseThrow(); + final TestRegistry reg = + new TestRegistry( + Map.of(0, PROPOSER_0, 1, VALIDATOR_1), + Map.of(0, k0.publicRegistryForm(), 1, k1.publicRegistryForm())); + return new World( + new PqProposalEnforcement(ARMED_FROM, reg, CHAIN_ID), + new FalconSeal(0, Bytes.wrap(sig0)), + k0, + k1); + } + + // --------------------------------------------------------------------------------------------- + // 1. BELOW the arming height NOTHING changes. The condition for the binary to sit on the fleet. + // --------------------------------------------------------------------------------------------- + @Test + void belowTheArmingHeightAnUnsealedProposalIsAccepted() { + final World w = world(); + assertThat(w.enforcement().armedAt(ARMED_FROM - 1)).isFalse(); + assertThat(w.enforcement().refusal(ARMED_FROM - 1, ROUND, PROPOSER_0, DIGEST, Optional.empty())) + .isEmpty(); + } + + // --------------------------------------------------------------------------------------------- + // 2. ABOVE it, a PROPOSAL without a seal is refused. + // --------------------------------------------------------------------------------------------- + @Test + void aboveTheHeightAnUnsealedProposalIsRefused() { + final World w = world(); + final Optional refusal = + w.enforcement().refusal(ARMED_FROM, ROUND, PROPOSER_0, DIGEST, Optional.empty()); + assertThat(refusal).isPresent(); + assertThat(refusal.get()).contains("carries NO post-quantum seal"); + } + + // --------------------------------------------------------------------------------------------- + // 3. A GOOD seal from the proposer passes. + // --------------------------------------------------------------------------------------------- + @Test + void aGoodSealFromTheProposerPasses() { + final World w = world(); + assertThat(w.enforcement().refusal(ARMED_FROM, ROUND, PROPOSER_0, DIGEST, Optional.of(w.valid0()))) + .isEmpty(); + } + + // --------------------------------------------------------------------------------------------- + // 4. The same seal, a different author: it does not vouch for somebody else. + // --------------------------------------------------------------------------------------------- + @Test + void aSealDoesNotVouchForAnotherProposer() { + final World w = world(); + final Optional refusal = + w.enforcement().refusal(ARMED_FROM, ROUND, VALIDATOR_1, DIGEST, Optional.of(w.valid0())); + assertThat(refusal).isPresent(); + assertThat(refusal.get()).contains("cannot vouch for someone else's proposal"); + } + + // --------------------------------------------------------------------------------------------- + // 5. DOMAIN SEPARATION, in both directions that matter here: a seal given HONESTLY over the + // PREPARE message, and one over the COMMIT message, must not pass as a PROPOSAL seal. Either + // passing would make honest seals transferable between an offer and a vote. + // --------------------------------------------------------------------------------------------- + @Test + void aSealOverThePREPAREMessageDoesNotPassAsPROPOSAL() { + final World w = world(); + final Bytes32 prepareMsg = PqAnchor.prepareMessage(CHAIN_ID, ARMED_FROM, ROUND, DIGEST.getBytes()); + final byte[] sig = + SealSchemes.FALCON_512.sign(w.k0().privateKey(), prepareMsg.toArray()).orElseThrow(); + + final Optional refusal = + w.enforcement() + .refusal( + ARMED_FROM, ROUND, PROPOSER_0, DIGEST, Optional.of(new FalconSeal(0, Bytes.wrap(sig)))); + assertThat(refusal).isPresent(); + assertThat(refusal.get()).contains("does NOT verify over the proposal message"); + } + + @Test + void aSealOverTheCOMMITMessageDoesNotPassAsPROPOSAL() { + final World w = world(); + final Bytes32 commitMsg = PqAnchor.commitMessage(CHAIN_ID, ARMED_FROM, DIGEST.getBytes()); + final byte[] sig = + SealSchemes.FALCON_512.sign(w.k0().privateKey(), commitMsg.toArray()).orElseThrow(); + + final Optional refusal = + w.enforcement() + .refusal( + ARMED_FROM, ROUND, PROPOSER_0, DIGEST, Optional.of(new FalconSeal(0, Bytes.wrap(sig)))); + assertThat(refusal).isPresent(); + assertThat(refusal.get()).contains("does NOT verify over the proposal message"); + } + + // --------------------------------------------------------------------------------------------- + // 6. The ROUND is in the preimage: a seal from a failed round does not open another one. + // --------------------------------------------------------------------------------------------- + @Test + void aSealFromAnotherROUNDDoesNotPass() { + final World w = world(); + final byte[] sigOtherRound = + SealSchemes.FALCON_512 + .sign(w.k0().privateKey(), proposalMessage(ARMED_FROM, ROUND + 1).toArray()) + .orElseThrow(); + final Optional refusal = + w.enforcement() + .refusal( + ARMED_FROM, + ROUND, + PROPOSER_0, + DIGEST, + Optional.of(new FalconSeal(0, Bytes.wrap(sigOtherRound)))); + assertThat(refusal).isPresent(); + assertThat(refusal.get()).contains("does NOT verify over the proposal message"); + } + + // --------------------------------------------------------------------------------------------- + // 7. An index the registry binds to nobody. + // --------------------------------------------------------------------------------------------- + @Test + void anUNBOUNDIndexDoesNotPass() { + final World w = world(); + final Optional refusal = + w.enforcement() + .refusal( + ARMED_FROM, + ROUND, + PROPOSER_0, + DIGEST, + Optional.of(new FalconSeal(99, w.valid0().getSignature()))); + assertThat(refusal).isPresent(); + assertThat(refusal.get()).contains("is bound to null"); + } + + // --------------------------------------------------------------------------------------------- + // 8. CONFIGURATION: absent = disarmed; a mistyped value = REFUSAL, never a silent disarming. + // --------------------------------------------------------------------------------------------- + @Test + void withoutThePropertyTheEnforcementIsNULL() { + assertThat(PqProposalEnforcement.fromSystemConfig()).isNull(); + } + + @Test + void aGoodValueArms() { + System.setProperty(PqProposalEnforcement.PROPERTY_FORK_BLOCK, "1234567"); + final PqProposalEnforcement e = PqProposalEnforcement.fromSystemConfig(); + assertThat(e).isNotNull(); + assertThat(e.armedAt(1_234_566L)).isFalse(); + assertThat(e.armedAt(1_234_567L)).isTrue(); + } + + @Test + void aMISTYPEDValueRefusesLoudly() { + for (final String bad : new String[] {"not-a-number", "1_234_567", "-1", "1e6"}) { + System.setProperty(PqProposalEnforcement.PROPERTY_FORK_BLOCK, bad); + assertThatThrownBy(PqProposalEnforcement::fromSystemConfig) + .as("the value '%s'", bad) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("AERE-PQC-PROPOSAL-ENF-01"); + } + } + + @Test + void theENFORCEMENTGateIsNotTheEMISSIONGate() { + // Two distinct properties: a fleet can EMIT for months before anything ENFORCES. If they were + // one switch, the first node that started emitting would also start refusing - a flag day. + assertThat(PqProposalEnforcement.PROPERTY_FORK_BLOCK) + .isNotEqualTo("aere.pq.proposalPq.attachBlock"); + } +} diff --git a/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqRoundChangeEnforcementTest.java b/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqRoundChangeEnforcementTest.java new file mode 100755 index 000000000..5e6b486ab --- /dev/null +++ b/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqRoundChangeEnforcementTest.java @@ -0,0 +1,422 @@ +/* + * Copyright contributors to Besu. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.qbft.core.validation; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.hyperledger.besu.consensus.common.bft.FalconSeal; +import org.hyperledger.besu.consensus.common.bft.PqAnchor; +import org.hyperledger.besu.consensus.common.bft.PqSignerRegistry; +import org.hyperledger.besu.consensus.common.bft.SealScheme; +import org.hyperledger.besu.consensus.common.bft.SealSchemes; +import org.hyperledger.besu.consensus.qbft.core.payload.PreparedRoundMetadata; +import org.hyperledger.besu.crypto.SecureRandomProvider; +import org.hyperledger.besu.datatypes.Address; +import org.hyperledger.besu.datatypes.Hash; + +import java.security.SecureRandom; +import java.util.Map; +import java.util.Optional; + +import org.apache.tuweni.bytes.Bytes; +import org.apache.tuweni.bytes.Bytes32; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +/** + * ROUND-CHANGE ENFORCEMENT. The structure follows PqProposalEnforcementTest deliberately - the + * same ways of being wrong, each with the test that closes it, plus the one that is round-change + * specific: the PREPARED METADATA is in the preimage, so a seal from a bare round-change cannot be + * replayed onto one that claims a prepared block, in either direction. + * + *

    + *
  1. below the arming height NOTHING changes - the condition for the binary to sit on the fleet; + *
  2. above it, a ROUND-CHANGE without a seal is refused; + *
  3. a seal from ANOTHER validator does not vouch for this author; + *
  4. a seal over a DIFFERENT MESSAGE does not pass - tested against the PREPARE, COMMIT and + * PROPOSAL messages, because a round-change seal that passed under any of those domains + * would let an honest seal be replayed as a different kind of assertion; + *
  5. a seal from a DIFFERENT TARGET ROUND does not pass; + *
  6. a seal from a BARE round-change does not pass on one claiming a prepared block, and vice + * versa; + *
  7. an index the registry binds to nobody does not pass; + *
  8. a mistyped configuration REFUSES, it does not disarm; + *
  9. the enforcement gate and the emission gate are two different switches. + *
+ * + *

The keys are REAL Falcon keys, generated in-process, and verification goes through the real + * scheme. A test with fake signatures would prove that we can compare strings, not that the + * enforcement enforces. + */ +class PqRoundChangeEnforcementTest { + + private static final long ARMED_FROM = 1_000_000L; + private static final int TARGET_ROUND = 3; + private static final Address AUTHOR_0 = Address.fromHexString("0x" + "aa".repeat(20)); + private static final Address VALIDATOR_1 = Address.fromHexString("0x" + "bb".repeat(20)); + private static final Hash PREPARED_DIGEST = Hash.hash(Bytes.of(8, 8, 8)); + private static final long CHAIN_ID = 2800L; + + private final SecureRandom random = SecureRandomProvider.createSecureRandom(); + + @AfterEach + void clearProperty() { + System.clearProperty(PqRoundChangeEnforcement.PROPERTY_FORK_BLOCK); + } + + /** Test registry: programmed index-to-address bindings + verification through the REAL scheme. */ + private static final class TestRegistry implements PqSignerRegistry { + final Map bindings; + final Map keys; + + TestRegistry(final Map bindings, final Map keys) { + this.bindings = bindings; + this.keys = keys; + } + + @Override + public Address addressForIndexAtHistoric(final long blockNumber, final int validatorIndex) { + return bindings.get(validatorIndex); + } + + @Override + public Address addressForIndexAtOwnHead(final long blockNumber, final int validatorIndex) { + return bindings.get(validatorIndex); + } + + @Override + public boolean verifyAtHistoric( + final long blockNumber, final int validatorIndex, final Bytes message, final Bytes signature) { + return verifyAtOwnHead(blockNumber, validatorIndex, message, signature); + } + + @Override + public boolean verifyAtOwnHead( + final long blockNumber, final int validatorIndex, final Bytes message, final Bytes signature) { + final byte[] pk = keys.get(validatorIndex); + if (pk == null) { + return false; + } + return SealSchemes.FALCON_512.verifyRaw(pk, message.toArray(), signature.toArray()); + } + } + + private record World( + PqRoundChangeEnforcement enforcement, + FalconSeal validBare0, + SealScheme.GeneratedPair k0, + SealScheme.GeneratedPair k1) {} + + private Bytes32 bareMessage(final long h, final int targetRound) { + return PqAnchor.roundChangeMessage(CHAIN_ID, h, targetRound); + } + + private Bytes32 preparedMessage(final long h, final int targetRound, final int preparedRound) { + return PqAnchor.roundChangeMessage( + CHAIN_ID, h, targetRound, preparedRound, PREPARED_DIGEST.getBytes()); + } + + private static Optional preparedMetadata(final int preparedRound) { + return Optional.of(new PreparedRoundMetadata(PREPARED_DIGEST, preparedRound)); + } + + /** Two validators with real Falcon keys; validator 0's seal over the BARE round-change message. */ + private World world() { + final SealScheme.GeneratedPair k0 = SealSchemes.FALCON_512.generate(random); + final SealScheme.GeneratedPair k1 = SealSchemes.FALCON_512.generate(random); + final byte[] sig0 = + SealSchemes.FALCON_512 + .sign(k0.privateKey(), bareMessage(ARMED_FROM, TARGET_ROUND).toArray()) + .orElseThrow(); + final TestRegistry reg = + new TestRegistry( + Map.of(0, AUTHOR_0, 1, VALIDATOR_1), + Map.of(0, k0.publicRegistryForm(), 1, k1.publicRegistryForm())); + return new World( + new PqRoundChangeEnforcement(ARMED_FROM, reg, CHAIN_ID), + new FalconSeal(0, Bytes.wrap(sig0)), + k0, + k1); + } + + // --------------------------------------------------------------------------------------------- + // 1. BELOW the arming height NOTHING changes. The condition for the binary to sit on the fleet. + // --------------------------------------------------------------------------------------------- + @Test + void belowTheArmingHeightAnUnsealedRoundChangeIsAccepted() { + final World w = world(); + assertThat(w.enforcement().armedAt(ARMED_FROM - 1)).isFalse(); + assertThat( + w.enforcement() + .refusal(ARMED_FROM - 1, TARGET_ROUND, AUTHOR_0, Optional.empty(), Optional.empty())) + .isEmpty(); + } + + // --------------------------------------------------------------------------------------------- + // 2. ABOVE it, a ROUND-CHANGE without a seal is refused. + // --------------------------------------------------------------------------------------------- + @Test + void aboveTheHeightAnUnsealedRoundChangeIsRefused() { + final World w = world(); + final Optional refusal = + w.enforcement() + .refusal(ARMED_FROM, TARGET_ROUND, AUTHOR_0, Optional.empty(), Optional.empty()); + assertThat(refusal).isPresent(); + assertThat(refusal.get()).contains("carries NO post-quantum seal"); + } + + // --------------------------------------------------------------------------------------------- + // 3. A GOOD seal from the author passes - bare, and with prepared metadata. + // --------------------------------------------------------------------------------------------- + @Test + void aGoodSealFromTheAuthorPasses() { + final World w = world(); + assertThat( + w.enforcement() + .refusal( + ARMED_FROM, + TARGET_ROUND, + AUTHOR_0, + Optional.empty(), + Optional.of(w.validBare0()))) + .isEmpty(); + } + + @Test + void aGoodSealOverPreparedMetadataPasses() { + final World w = world(); + final byte[] sig = + SealSchemes.FALCON_512 + .sign(w.k0().privateKey(), preparedMessage(ARMED_FROM, TARGET_ROUND, 1).toArray()) + .orElseThrow(); + assertThat( + w.enforcement() + .refusal( + ARMED_FROM, + TARGET_ROUND, + AUTHOR_0, + preparedMetadata(1), + Optional.of(new FalconSeal(0, Bytes.wrap(sig))))) + .isEmpty(); + } + + // --------------------------------------------------------------------------------------------- + // 4. The same seal, a different author: it does not vouch for somebody else. + // --------------------------------------------------------------------------------------------- + @Test + void aSealDoesNotVouchForAnotherAuthor() { + final World w = world(); + final Optional refusal = + w.enforcement() + .refusal( + ARMED_FROM, TARGET_ROUND, VALIDATOR_1, Optional.empty(), Optional.of(w.validBare0())); + assertThat(refusal).isPresent(); + assertThat(refusal.get()).contains("cannot vouch for someone else's round-change"); + } + + // --------------------------------------------------------------------------------------------- + // 5. DOMAIN SEPARATION against all three sibling domains: a seal given HONESTLY over the + // PREPARE, COMMIT or PROPOSAL message must not pass as a ROUND-CHANGE seal. + // --------------------------------------------------------------------------------------------- + @Test + void aSealOverThePREPAREMessageDoesNotPassAsROUNDCHANGE() { + final World w = world(); + final Bytes32 prepareMsg = + PqAnchor.prepareMessage(CHAIN_ID, ARMED_FROM, TARGET_ROUND, PREPARED_DIGEST.getBytes()); + final byte[] sig = + SealSchemes.FALCON_512.sign(w.k0().privateKey(), prepareMsg.toArray()).orElseThrow(); + + final Optional refusal = + w.enforcement() + .refusal( + ARMED_FROM, + TARGET_ROUND, + AUTHOR_0, + Optional.empty(), + Optional.of(new FalconSeal(0, Bytes.wrap(sig)))); + assertThat(refusal).isPresent(); + assertThat(refusal.get()).contains("does NOT verify over the round-change message"); + } + + @Test + void aSealOverTheCOMMITMessageDoesNotPassAsROUNDCHANGE() { + final World w = world(); + final Bytes32 commitMsg = PqAnchor.commitMessage(CHAIN_ID, ARMED_FROM, PREPARED_DIGEST.getBytes()); + final byte[] sig = + SealSchemes.FALCON_512.sign(w.k0().privateKey(), commitMsg.toArray()).orElseThrow(); + + final Optional refusal = + w.enforcement() + .refusal( + ARMED_FROM, + TARGET_ROUND, + AUTHOR_0, + Optional.empty(), + Optional.of(new FalconSeal(0, Bytes.wrap(sig)))); + assertThat(refusal).isPresent(); + assertThat(refusal.get()).contains("does NOT verify over the round-change message"); + } + + @Test + void aSealOverThePROPOSALMessageDoesNotPassAsROUNDCHANGE() { + final World w = world(); + final Bytes32 proposalMsg = + PqAnchor.proposalMessage(CHAIN_ID, ARMED_FROM, TARGET_ROUND, PREPARED_DIGEST.getBytes()); + final byte[] sig = + SealSchemes.FALCON_512.sign(w.k0().privateKey(), proposalMsg.toArray()).orElseThrow(); + + final Optional refusal = + w.enforcement() + .refusal( + ARMED_FROM, + TARGET_ROUND, + AUTHOR_0, + Optional.empty(), + Optional.of(new FalconSeal(0, Bytes.wrap(sig)))); + assertThat(refusal).isPresent(); + assertThat(refusal.get()).contains("does NOT verify over the round-change message"); + } + + // --------------------------------------------------------------------------------------------- + // 6. The TARGET ROUND is in the preimage: a seal towards one round does not open another. + // --------------------------------------------------------------------------------------------- + @Test + void aSealTowardsAnotherTARGETROUNDDoesNotPass() { + final World w = world(); + final byte[] sigOtherRound = + SealSchemes.FALCON_512 + .sign(w.k0().privateKey(), bareMessage(ARMED_FROM, TARGET_ROUND + 1).toArray()) + .orElseThrow(); + final Optional refusal = + w.enforcement() + .refusal( + ARMED_FROM, + TARGET_ROUND, + AUTHOR_0, + Optional.empty(), + Optional.of(new FalconSeal(0, Bytes.wrap(sigOtherRound)))); + assertThat(refusal).isPresent(); + assertThat(refusal.get()).contains("does NOT verify over the round-change message"); + } + + // --------------------------------------------------------------------------------------------- + // 7. THE ROUND-CHANGE-SPECIFIC CASE: the prepared metadata is in the preimage, in BOTH + // directions. A bare seal on a message claiming a prepared block would let an adversary take + // an honest "just move on" and turn it into "move on AND re-propose THIS block". + // --------------------------------------------------------------------------------------------- + @Test + void aBareSealDoesNotPassOnARoundChangeClaimingAPreparedBlock() { + final World w = world(); + final Optional refusal = + w.enforcement() + .refusal( + ARMED_FROM, TARGET_ROUND, AUTHOR_0, preparedMetadata(1), Optional.of(w.validBare0())); + assertThat(refusal).isPresent(); + assertThat(refusal.get()).contains("does NOT verify over the round-change message"); + } + + @Test + void aPreparedSealDoesNotPassOnABareRoundChange() { + final World w = world(); + final byte[] sigPrepared = + SealSchemes.FALCON_512 + .sign(w.k0().privateKey(), preparedMessage(ARMED_FROM, TARGET_ROUND, 1).toArray()) + .orElseThrow(); + final Optional refusal = + w.enforcement() + .refusal( + ARMED_FROM, + TARGET_ROUND, + AUTHOR_0, + Optional.empty(), + Optional.of(new FalconSeal(0, Bytes.wrap(sigPrepared)))); + assertThat(refusal).isPresent(); + assertThat(refusal.get()).contains("does NOT verify over the round-change message"); + } + + @Test + void aSealOverDifferentPreparedMetadataDoesNotPass() { + final World w = world(); + final byte[] sigRound1 = + SealSchemes.FALCON_512 + .sign(w.k0().privateKey(), preparedMessage(ARMED_FROM, TARGET_ROUND, 1).toArray()) + .orElseThrow(); + final Optional refusal = + w.enforcement() + .refusal( + ARMED_FROM, + TARGET_ROUND, + AUTHOR_0, + preparedMetadata(2), + Optional.of(new FalconSeal(0, Bytes.wrap(sigRound1)))); + assertThat(refusal).isPresent(); + assertThat(refusal.get()).contains("does NOT verify over the round-change message"); + } + + // --------------------------------------------------------------------------------------------- + // 8. An index the registry binds to nobody. + // --------------------------------------------------------------------------------------------- + @Test + void anUNBOUNDIndexDoesNotPass() { + final World w = world(); + final Optional refusal = + w.enforcement() + .refusal( + ARMED_FROM, + TARGET_ROUND, + AUTHOR_0, + Optional.empty(), + Optional.of(new FalconSeal(99, w.validBare0().getSignature()))); + assertThat(refusal).isPresent(); + assertThat(refusal.get()).contains("is bound to null"); + } + + // --------------------------------------------------------------------------------------------- + // 9. CONFIGURATION: absent = disarmed; a mistyped value = REFUSAL, never a silent disarming. + // --------------------------------------------------------------------------------------------- + @Test + void withoutThePropertyTheEnforcementIsNULL() { + assertThat(PqRoundChangeEnforcement.fromSystemConfig()).isNull(); + } + + @Test + void aGoodValueArms() { + System.setProperty(PqRoundChangeEnforcement.PROPERTY_FORK_BLOCK, "1234567"); + final PqRoundChangeEnforcement e = PqRoundChangeEnforcement.fromSystemConfig(); + assertThat(e).isNotNull(); + assertThat(e.armedAt(1_234_566L)).isFalse(); + assertThat(e.armedAt(1_234_567L)).isTrue(); + } + + @Test + void aMISTYPEDValueRefusesLoudly() { + for (final String bad : new String[] {"not-a-number", "1_234_567", "-1", "1e6"}) { + System.setProperty(PqRoundChangeEnforcement.PROPERTY_FORK_BLOCK, bad); + assertThatThrownBy(PqRoundChangeEnforcement::fromSystemConfig) + .as("the value '%s'", bad) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("AERE-PQC-ROUNDCHANGE-ENF-01"); + } + } + + @Test + void theENFORCEMENTGateIsNotTheEMISSIONGate() { + // Two distinct properties: a fleet can EMIT for months before anything ENFORCES. If they were + // one switch, the first node that started emitting would also start refusing - a flag day. + assertThat(PqRoundChangeEnforcement.PROPERTY_FORK_BLOCK) + .isNotEqualTo("aere.pq.roundChangePq.attachBlock"); + } +} diff --git a/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqTransportIndependenceTest.java b/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqTransportIndependenceTest.java new file mode 100755 index 000000000..a276d30f5 --- /dev/null +++ b/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqTransportIndependenceTest.java @@ -0,0 +1,437 @@ +/* + * Copyright contributors to Besu. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.qbft.core.validation; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.hyperledger.besu.consensus.common.bft.ConsensusRoundIdentifier; +import org.hyperledger.besu.consensus.common.bft.FalconSeal; +import org.hyperledger.besu.consensus.common.bft.PqAnchor; +import org.hyperledger.besu.consensus.common.bft.PqSignerRegistry; +import org.hyperledger.besu.consensus.common.bft.SealScheme; +import org.hyperledger.besu.consensus.common.bft.SealSchemes; +import org.hyperledger.besu.consensus.qbft.core.payload.PreparedRoundMetadata; +import org.hyperledger.besu.crypto.SecureRandomProvider; +import org.hyperledger.besu.datatypes.Address; +import org.hyperledger.besu.datatypes.Hash; + +import java.security.SecureRandom; +import java.util.Map; +import java.util.Optional; + +import org.apache.tuweni.bytes.Bytes; +import org.apache.tuweni.bytes.Bytes32; +import org.junit.jupiter.api.Test; + +/** + * THE ADVERSARY WHO ALREADY HAS THE VALIDATOR'S ECDSA KEY, and still cannot move the chain. + * + *

WHY THIS TEST IS THE ONE THAT MATTERS. The network transport (RLPx/ECIES) authenticates + * a peer by its secp256k1 node key, and {@code ValidatorPeers} routes consensus messages by the + * address derived from that same key. A quantum adversary who recovers a validator's ECDSA key + * therefore gets EVERYTHING the transport can give: it completes the handshake as that validator, + * is routed consensus traffic as that validator, and signs syntactically perfect QBFT messages that + * recover to that validator's address. Every check that predates the post-quantum layers passes for + * it. + * + *

The question this test answers, by measurement rather than by argument: with the + * post-quantum enforcement armed, does that adversary gain anything at all? It models exactly + * that adversary - full ECDSA compromise, no Falcon key - and requires all four hot-path messages + * to be refused. That is what "consensus safety does not depend on the transport" means concretely, + * and it is the honest form of the claim: not that the transport is post-quantum (it is not), but + * that breaking it does not buy a break of safety. + * + *

THE CONTROLS, without which the refusals would prove nothing: + * + *

    + *
  1. DISARMED: the very same messages from the very same adversary are ACCEPTED when the + * enforcement is not armed. Without this, the refusals could come from any unrelated defect + * in the fixture and the test would be measuring its own mistake. + *
  2. THE HONEST VALIDATOR: a message carrying a VALID Falcon seal is accepted while armed. So + * the refusal is caused by the missing seal, not by arming per se - otherwise an enforcement + * that refuses everything would look identical to one that enforces. + *
  3. THE HARVESTED SEAL, in four flavours, because a broken transport hands the adversary the + * victim's entire message history to replay: a seal from another message KIND, one for + * another BLOCK at the same height and round, one from another HEIGHT, ROUND or CHAIN, and + * one belonging to ANOTHER VALIDATOR entirely, stapled onto the adversary's own message. + *
+ * + *

TWO OF THOSE CONTROLS EXIST BECAUSE THE NEGATIVE CONTROL CAUGHT THEIR ABSENCE + * (2026-08-31, {@code PROBA-CONTROL-INDEPENDENTA-TRANSPORT.sh}). The first version of this test + * passed with 8 green assertions, and stayed green under two planted defects: the digest removed + * from the signed preimage, and the index-to-author binding removed. Both were invisible because + * every replay it tried crossed a domain boundary, and because its registry held a single + * validator - so the two attacks a broken transport most directly enables could not even be + * expressed. Green, running, and blind. That is the whole reason a proof has to be shown red. + * + *

WHAT THIS DOES NOT PROVE, said plainly: nothing here is about LIVENESS. An adversary + * with a validator's ECDSA key can still occupy the connection slot, eclipse a peer, or flood it; + * those are denial-of-service questions and this test says nothing about them. It also says + * nothing about the fleet as configured today, where the enforcement heights are unset - the + * property proven is the property of the ARMED configuration. + */ +class PqTransportIndependenceTest { + + private static final long H = 1_000L; + private static final int ROUND = 2; + private static final long CHAIN_ID = 2800L; + + /** The validator whose ECDSA key the adversary has stolen. */ + private static final Address VICTIM = Address.fromHexString("0x" + "dd".repeat(20)); + + /** + * A SECOND, uncompromised validator. It exists because of what the negative control measured: + * with a single-validator registry, the scenario "the adversary staples someone else's seal - + * one it overheard on the broken wire - onto its own message" cannot even be expressed, and the + * planted removal of the index-to-author binding left the whole test GREEN. A test that cannot + * express the attack does not measure it. + */ + private static final Address OTHER = Address.fromHexString("0x" + "ee".repeat(20)); + + private static final Hash DIGEST = Hash.hash(Bytes.of(4, 2)); + + /** + * A DIFFERENT block digest at the same height and round. Also added after the negative control: + * every replay the first version tried crossed a DOMAIN boundary, so nothing required the + * digest itself to be under the seal - and the planted removal of the digest from the preimage + * went unnoticed. That removal is the difference between "this validator voted" and "this + * validator voted FOR THIS BLOCK". + */ + private static final Hash OTHER_DIGEST = Hash.hash(Bytes.of(7, 7)); + + private static final Hash COMMIT_DIGEST = Hash.hash(Bytes.of(4, 3)); + + private final SecureRandom random = SecureRandomProvider.createSecureRandom(); + + /** The victim's Falcon key pair - held by the HONEST node, never by the adversary. */ + private final SealScheme.GeneratedPair victimPq = SealSchemes.FALCON_512.generate(random); + + /** The second validator's Falcon key pair - likewise never in the adversary's hands. */ + private final SealScheme.GeneratedPair otherPq = SealSchemes.FALCON_512.generate(random); + + /** A registry binding index 0 to the victim and index 1 to the other validator. */ + private final PqSignerRegistry registry = + new PqSignerRegistry() { + private final Map bindings = Map.of(0, VICTIM, 1, OTHER); + private final Map keys = + Map.of(0, victimPq.publicRegistryForm(), 1, otherPq.publicRegistryForm()); + + @Override + public Address addressForIndexAtHistoric(final long blockNumber, final int validatorIndex) { + return bindings.get(validatorIndex); + } + + @Override + public Address addressForIndexAtOwnHead(final long blockNumber, final int validatorIndex) { + return bindings.get(validatorIndex); + } + + @Override + public boolean verifyAtHistoric( + final long blockNumber, final int validatorIndex, final Bytes message, final Bytes signature) { + return verifyAtOwnHead(blockNumber, validatorIndex, message, signature); + } + + @Override + public boolean verifyAtOwnHead( + final long blockNumber, final int validatorIndex, final Bytes message, final Bytes signature) { + final byte[] pk = keys.get(validatorIndex); + return pk != null + && SealSchemes.FALCON_512.verifyRaw(pk, message.toArray(), signature.toArray()); + } + }; + + /** A seal the HONEST victim would produce for the given message. */ + private FalconSeal honestSeal(final Bytes32 message) { + final byte[] sig = + SealSchemes.FALCON_512.sign(victimPq.privateKey(), message.toArray()).orElseThrow(); + return new FalconSeal(0, Bytes.wrap(sig)); + } + + /** + * A seal the OTHER honest validator would produce - the kind the adversary can hear on a broken + * wire and would like to reuse as its own. + */ + private FalconSeal otherValidatorSeal(final Bytes32 message) { + final byte[] sig = + SealSchemes.FALCON_512.sign(otherPq.privateKey(), message.toArray()).orElseThrow(); + return new FalconSeal(1, Bytes.wrap(sig)); + } + + private Bytes32 prepareMsg() { + return PqAnchor.prepareMessage(CHAIN_ID, H, ROUND, DIGEST.getBytes()); + } + + private Bytes32 proposalMsg() { + return PqAnchor.proposalMessage(CHAIN_ID, H, ROUND, DIGEST.getBytes()); + } + + /** + * The commit layer is the OLDEST of the four and its seal covers the commit digest DIRECTLY, + * without a domain-separated preimage - measured in {@code PqCommitEnforcement.falconRefusal}, + * not assumed. It is written out here rather than papered over: a test that signed a + * domain-separated message for commit would be testing a layer we do not have, and would fail + * for a reason that has nothing to do with the adversary being modelled. + */ + private Bytes32 commitMsg() { + return Bytes32.wrap(COMMIT_DIGEST.getBytes()); + } + + private Bytes32 roundChangeMsg() { + return PqAnchor.roundChangeMessage(CHAIN_ID, H, ROUND); + } + + // The four enforcements, armed at H. The adversary's messages recover to VICTIM, because the + // adversary holds VICTIM's ECDSA key: the author argument below is deliberately VICTIM. + private PqPrepareEnforcement prepareArmed() { + return new PqPrepareEnforcement(H, registry, CHAIN_ID); + } + + private PqProposalEnforcement proposalArmed() { + return new PqProposalEnforcement(H, registry, CHAIN_ID); + } + + private PqCommitEnforcement commitArmed() { + return new PqCommitEnforcement(H, registry); + } + + private PqRoundChangeEnforcement roundChangeArmed() { + return new PqRoundChangeEnforcement(H, registry, CHAIN_ID); + } + + // ----------------------------------------------------------------------------------------- + // THE MEASUREMENT: full ECDSA compromise, no Falcon key. All four messages refused. + // ----------------------------------------------------------------------------------------- + + @Test + void aStolenECDSAKeyBuysNOPrepare() { + final Optional refusal = + prepareArmed().refusal(H, ROUND, VICTIM, DIGEST, Optional.empty()); + assertThat(refusal) + .describedAs("an adversary holding the victim's ECDSA key must not be able to vote") + .isPresent(); + assertThat(refusal.get()).contains("carries NO post-quantum seal"); + } + + @Test + void aStolenECDSAKeyBuysNOProposal() { + final Optional refusal = + proposalArmed().refusal(H, ROUND, VICTIM, DIGEST, Optional.empty()); + assertThat(refusal).isPresent(); + assertThat(refusal.get()).contains("carries NO post-quantum seal"); + } + + @Test + void aStolenECDSAKeyBuysNOCommit() { + final Optional refusal = + commitArmed().refusal(H, VICTIM, COMMIT_DIGEST, Optional.empty()); + assertThat(refusal).isPresent(); + } + + @Test + void aStolenECDSAKeyBuysNORoundChange() { + final Optional refusal = + roundChangeArmed().refusal(H, ROUND, VICTIM, Optional.empty(), Optional.empty()); + assertThat(refusal).isPresent(); + assertThat(refusal.get()).contains("carries NO post-quantum seal"); + } + + // ----------------------------------------------------------------------------------------- + // CONTROL 1 - DISARMED: the very same messages pass. The refusals above are caused by the + // arming, not by something broken in this fixture. + // ----------------------------------------------------------------------------------------- + + @Test + void controlDISARMEDtheSameMessagesArePerfectlyValid() { + final long never = Long.MAX_VALUE; + assertThat( + new PqPrepareEnforcement(never, registry, CHAIN_ID) + .refusal(H, ROUND, VICTIM, DIGEST, Optional.empty())) + .isEmpty(); + assertThat( + new PqProposalEnforcement(never, registry, CHAIN_ID) + .refusal(H, ROUND, VICTIM, DIGEST, Optional.empty())) + .isEmpty(); + assertThat( + new PqCommitEnforcement(never, registry) + .refusal(H, VICTIM, COMMIT_DIGEST, Optional.empty())) + .isEmpty(); + assertThat( + new PqRoundChangeEnforcement(never, registry, CHAIN_ID) + .refusal(H, ROUND, VICTIM, Optional.empty(), Optional.empty())) + .isEmpty(); + } + + // ----------------------------------------------------------------------------------------- + // CONTROL 2 - THE HONEST VALIDATOR still works while armed. Otherwise "refuses everything" + // would be indistinguishable from "enforces". + // ----------------------------------------------------------------------------------------- + + @Test + void controlTheHONESTvalidatorPassesOnAllFourMessagesWhileArmed() { + assertThat( + prepareArmed().refusal(H, ROUND, VICTIM, DIGEST, Optional.of(honestSeal(prepareMsg())))) + .isEmpty(); + assertThat( + proposalArmed() + .refusal(H, ROUND, VICTIM, DIGEST, Optional.of(honestSeal(proposalMsg())))) + .isEmpty(); + assertThat(commitArmed().refusal(H, VICTIM, COMMIT_DIGEST, Optional.of(honestSeal(commitMsg())))) + .isEmpty(); + assertThat( + roundChangeArmed() + .refusal( + H, ROUND, VICTIM, Optional.empty(), Optional.of(honestSeal(roundChangeMsg())))) + .isEmpty(); + } + + // ----------------------------------------------------------------------------------------- + // CONTROL 3 - THE STOLEN SEAL. A broken transport lets the adversary READ every message the + // victim ever sent, so it can replay any seal it has seen. Each seal is bound to its own + // message, so a seal harvested from one message must not authenticate another. This is the + // control that turns "the adversary has no key" into "eavesdropping does not substitute for + // one" - and it is checked ACROSS message kinds (domain separation) and WITHIN a kind + // (a round-change seal replayed onto a claim of a prepared block). + // ----------------------------------------------------------------------------------------- + + @Test + void controlASealHARVESTEDfromAnotherMessageDoesNotAuthenticateThisOne() { + // a PREPARE seal, replayed on a PROPOSAL and on a ROUND-CHANGE + final FalconSeal harvestedPrepare = honestSeal(prepareMsg()); + assertThat(proposalArmed().refusal(H, ROUND, VICTIM, DIGEST, Optional.of(harvestedPrepare))) + .isPresent(); + assertThat( + roundChangeArmed() + .refusal(H, ROUND, VICTIM, Optional.empty(), Optional.of(harvestedPrepare))) + .isPresent(); + + // a PROPOSAL seal, replayed as a vote + final FalconSeal harvestedProposal = honestSeal(proposalMsg()); + assertThat(prepareArmed().refusal(H, ROUND, VICTIM, DIGEST, Optional.of(harvestedProposal))) + .isPresent(); + + // a bare ROUND-CHANGE seal, replayed on one that claims a prepared block: same kind, different + // assertion, and the metadata is in the preimage precisely so this fails + final FalconSeal harvestedRoundChange = honestSeal(roundChangeMsg()); + assertThat( + roundChangeArmed() + .refusal( + H, + ROUND, + VICTIM, + Optional.of(new PreparedRoundMetadata(DIGEST, 1)), + Optional.of(harvestedRoundChange))) + .isPresent(); + } + + // ----------------------------------------------------------------------------------------- + // CONTROL 3b - THE SEAL OF ANOTHER BLOCK, same kind, same height, same round. This is the + // difference between "this validator voted" and "this validator voted FOR THIS BLOCK", and it + // is the one an eavesdropper is best placed to exploit: on a broken wire it hears the victim's + // honest vote for block A and wants to turn it into a vote for block B. + // + // ADDED after the negative control caught its absence: every replay above crosses a DOMAIN + // boundary, so with the digest planted OUT of the signed message the whole test stayed GREEN. + // ----------------------------------------------------------------------------------------- + + @Test + void controlASealForANOTHERBLOCKDoesNotAuthenticateThisOne() { + final FalconSeal forOtherBlock = + honestSeal(PqAnchor.prepareMessage(CHAIN_ID, H, ROUND, OTHER_DIGEST.getBytes())); + assertThat(prepareArmed().refusal(H, ROUND, VICTIM, DIGEST, Optional.of(forOtherBlock))) + .describedAs("a vote for one block must not authenticate a vote for another") + .isPresent(); + + final FalconSeal proposalForOtherBlock = + honestSeal(PqAnchor.proposalMessage(CHAIN_ID, H, ROUND, OTHER_DIGEST.getBytes())); + assertThat(proposalArmed().refusal(H, ROUND, VICTIM, DIGEST, Optional.of(proposalForOtherBlock))) + .isPresent(); + + // and the commit layer, whose seal covers the commit digest directly + final FalconSeal commitForOtherBlock = honestSeal(Bytes32.wrap(OTHER_DIGEST.getBytes())); + assertThat(commitArmed().refusal(H, VICTIM, COMMIT_DIGEST, Optional.of(commitForOtherBlock))) + .isPresent(); + } + + // ----------------------------------------------------------------------------------------- + // CONTROL 3c - SOMEONE ELSE'S SEAL, stapled on. The adversary hears validator 1's perfectly + // valid seal for the very message it wants to send, and attaches it to its own message - which + // recovers to the VICTIM, because that is the ECDSA key it stole. The seal is valid, the + // signature is valid, the author is a real validator: only the index-to-author binding stands + // between that and a counted vote. + // + // ADDED after the negative control: with a single-validator registry this attack could not even + // be written, and the planted removal of that binding left the test GREEN. + // ----------------------------------------------------------------------------------------- + + @Test + void controlSOMEONEELSESsealDoesNotAuthenticateTheAdversarysMessage() { + final FalconSeal theirs = otherValidatorSeal(prepareMsg()); + + // sanity: that seal really is valid FOR ITS OWN AUTHOR, or the refusal below would prove + // nothing about the binding and everything about a broken fixture + assertThat(prepareArmed().refusal(H, ROUND, OTHER, DIGEST, Optional.of(theirs))) + .describedAs("validator 1's own seal must work for validator 1") + .isEmpty(); + + // the attack: the same valid seal, on a message authored with the victim's stolen key + assertThat(prepareArmed().refusal(H, ROUND, VICTIM, DIGEST, Optional.of(theirs))) + .describedAs("a seal must not vouch for a message someone else authored") + .isPresent(); + + // the same, on the other three surfaces + assertThat( + proposalArmed() + .refusal(H, ROUND, VICTIM, DIGEST, Optional.of(otherValidatorSeal(proposalMsg())))) + .isPresent(); + assertThat( + commitArmed() + .refusal(H, VICTIM, COMMIT_DIGEST, Optional.of(otherValidatorSeal(commitMsg())))) + .isPresent(); + assertThat( + roundChangeArmed() + .refusal( + H, + ROUND, + VICTIM, + Optional.empty(), + Optional.of(otherValidatorSeal(roundChangeMsg())))) + .isPresent(); + } + + // ----------------------------------------------------------------------------------------- + // CONTROL 4 - A SEAL FROM ANOTHER HEIGHT OR ROUND. The transport gives the adversary the + // victim's whole message history, so the seals it can replay are not only from other kinds but + // from other positions in the chain. + // ----------------------------------------------------------------------------------------- + + @Test + void controlASealFromAnotherHEIGHTorROUNDDoesNotAuthenticateThisOne() { + final FalconSeal otherHeight = + honestSeal(PqAnchor.prepareMessage(CHAIN_ID, H + 1, ROUND, DIGEST.getBytes())); + assertThat(prepareArmed().refusal(H, ROUND, VICTIM, DIGEST, Optional.of(otherHeight))) + .isPresent(); + + final FalconSeal otherRound = + honestSeal(PqAnchor.prepareMessage(CHAIN_ID, H, ROUND + 1, DIGEST.getBytes())); + assertThat(prepareArmed().refusal(H, ROUND, VICTIM, DIGEST, Optional.of(otherRound))) + .isPresent(); + + final FalconSeal otherChain = + honestSeal(PqAnchor.prepareMessage(CHAIN_ID + 1, H, ROUND, DIGEST.getBytes())); + assertThat(prepareArmed().refusal(H, ROUND, VICTIM, DIGEST, Optional.of(otherChain))) + .isPresent(); + } +} diff --git a/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PrepareValidatorPqWiringTest.java b/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PrepareValidatorPqWiringTest.java new file mode 100755 index 000000000..4ea938d39 --- /dev/null +++ b/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PrepareValidatorPqWiringTest.java @@ -0,0 +1,118 @@ +/* + * Copyright contributors to Besu. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.qbft.core.validation; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.hyperledger.besu.consensus.common.bft.ConsensusRoundIdentifier; +import org.hyperledger.besu.consensus.common.bft.PqSignerRegistry; +import org.hyperledger.besu.consensus.qbft.core.messagewrappers.Prepare; +import org.hyperledger.besu.consensus.qbft.core.types.QbftBlockCodec; +import org.hyperledger.besu.datatypes.Address; +import org.hyperledger.besu.datatypes.Hash; + +import org.apache.tuweni.bytes.Bytes; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; + +/** + * THE WIRING, not the class: does {@link PrepareValidator} actually CALL the enforcement? + * + *

Without this test we would have exactly the situation paid for on 2026-08-28 at the restore + * step - a class present in the binary, environment variables visible to the process, and code that + * never runs. "I checked what I added" does not mean "I checked that it is wired". + * + *

The registry here REFUSES everything, so this does not measure cryptography (that has its own + * test), only whether the decision passes through the hook at all. The pair below is all it takes: + * the same message, once with enforcement and once without. + */ +public class PrepareValidatorPqWiringTest { + + private static final int VALIDATOR_COUNT = 4; + private static final long HEIGHT = 1L; + + private final ConsensusRoundIdentifier round = new ConsensusRoundIdentifier((int) HEIGHT, 0); + private final Hash expectedHash = Hash.fromHexStringLenient("0x1"); + @Mock private QbftBlockCodec blockEncoder; + private QbftNodeList validators; + + @BeforeEach + public void setup() { + validators = QbftNodeList.createNodes(VALIDATOR_COUNT, blockEncoder); + } + + /** A registry that binds no index and verifies nothing. */ + private static final class RegistruGol implements PqSignerRegistry { + @Override + public Address addressForIndexAtHistoric(final long blockNumber, final int validatorIndex) { + return null; + } + + @Override + public Address addressForIndexAtOwnHead(final long blockNumber, final int validatorIndex) { + return null; + } + + @Override + public boolean verifyAtHistoric( + final long blockNumber, final int validatorIndex, final Bytes message, final Bytes signature) { + return false; + } + + @Override + public boolean verifyAtOwnHead( + final long blockNumber, final int validatorIndex, final Bytes message, final Bytes signature) { + return false; + } + } + + @Test + public void withoutEnforcementAnUnsealedPrepareISValid() { + final PrepareValidator validator = + new PrepareValidator(validators.getNodeAddresses(), round, expectedHash, null); + final Prepare msg = validators.getMessageFactory(0).createPrepare(round, expectedHash); + assertThat(validator.validate(msg)).isTrue(); + } + + @Test + public void withTheEnforcementARMEDTheSamePrepareISRefused() { + // THE SAME message as above. The only difference is the hook, so a different result means it + // really is called. If this still returned true, the enforcement would be dead code. + final PrepareValidator validator = + new PrepareValidator( + validators.getNodeAddresses(), + round, + expectedHash, + new PqPrepareEnforcement(HEIGHT, new RegistruGol(), 2800L)); + final Prepare msg = validators.getMessageFactory(0).createPrepare(round, expectedHash); + assertThat(validator.validate(msg)).isFalse(); + } + + @Test + public void withTheEnforcementBELOWItsHeightTheSamePrepareISValid() { + // The third state, closing the last way of being wrong: a hook that refused regardless of + // height would make the binary impossible to deploy. Here the enforcement exists but does not + // apply yet. + final PrepareValidator validator = + new PrepareValidator( + validators.getNodeAddresses(), + round, + expectedHash, + new PqPrepareEnforcement(HEIGHT + 1, new RegistruGol(), 2800L)); + final Prepare msg = validators.getMessageFactory(0).createPrepare(round, expectedHash); + assertThat(validator.validate(msg)).isTrue(); + } +} diff --git a/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/ProposalPayloadValidatorPqWiringTest.java b/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/ProposalPayloadValidatorPqWiringTest.java new file mode 100755 index 000000000..b9bea2210 --- /dev/null +++ b/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/ProposalPayloadValidatorPqWiringTest.java @@ -0,0 +1,133 @@ +/* + * Copyright contributors to Besu. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.qbft.core.validation; + +import static java.util.Collections.emptyList; +import static org.assertj.core.api.Assertions.assertThat; + +import org.hyperledger.besu.consensus.common.bft.ConsensusRoundIdentifier; +import org.hyperledger.besu.consensus.common.bft.PqSignerRegistry; +import org.hyperledger.besu.consensus.qbft.core.QbftBlockTestFixture; +import org.hyperledger.besu.consensus.qbft.core.messagewrappers.Proposal; +import org.hyperledger.besu.consensus.qbft.core.payload.MessageFactory; +import org.hyperledger.besu.consensus.qbft.core.types.QbftBlock; +import org.hyperledger.besu.consensus.qbft.core.types.QbftBlockCodec; +import org.hyperledger.besu.consensus.qbft.core.types.QbftBlockHeader; +import org.hyperledger.besu.cryptoservices.NodeKey; +import org.hyperledger.besu.cryptoservices.NodeKeyUtils; +import org.hyperledger.besu.datatypes.Address; +import org.hyperledger.besu.ethereum.core.Util; + +import java.util.Optional; + +import org.apache.tuweni.bytes.Bytes; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +/** + * THE WIRING, not the class: does {@link ProposalPayloadValidator} actually CALL the enforcement? + * + *

Same reason the PREPARE twin exists: a class present in the binary, environment variables + * visible to the process, and code that never runs is exactly the failure paid for on 2026-08-28. + * The registry here REFUSES everything, so this does not measure cryptography (that has its own + * test), only whether the decision passes through the hook. The pair is all it takes: the same + * signed proposal, once with enforcement and once without. + */ +@ExtendWith(MockitoExtension.class) +public class ProposalPayloadValidatorPqWiringTest { + + private static final long HEIGHT = 1L; + + private final ConsensusRoundIdentifier round = new ConsensusRoundIdentifier((int) HEIGHT, 0); + @Mock private QbftBlockCodec blockEncoder; + + private final NodeKey nodeKey = NodeKeyUtils.generate(); + private final Address proposer = Util.publicKeyToAddress(nodeKey.getPublicKey()); + private MessageFactory messageFactory; + + @BeforeEach + public void setup() { + messageFactory = new MessageFactory(nodeKey, blockEncoder); + } + + /** A registry that binds no index and verifies nothing. */ + private static final class EmptyRegistry implements PqSignerRegistry { + @Override + public Address addressForIndexAtHistoric(final long blockNumber, final int validatorIndex) { + return null; + } + + @Override + public Address addressForIndexAtOwnHead(final long blockNumber, final int validatorIndex) { + return null; + } + + @Override + public boolean verifyAtHistoric( + final long blockNumber, final int validatorIndex, final Bytes message, final Bytes signature) { + return false; + } + + @Override + public boolean verifyAtOwnHead( + final long blockNumber, final int validatorIndex, final Bytes message, final Bytes signature) { + return false; + } + } + + private Proposal unsealedProposal() { + final QbftBlockHeader header = + new QbftBlockHeaderTestFixture().number(round.getSequenceNumber()).buildHeader(); + final QbftBlock block = new QbftBlockTestFixture().blockHeader(header).build(); + return messageFactory.createProposal(round, block, Optional.empty(), emptyList(), emptyList()); + } + + @Test + public void withoutEnforcementAnUnsealedProposalISValid() { + final ProposalPayloadValidator validator = + new ProposalPayloadValidator(proposer, round, null, null); + assertThat(validator.validateWithoutBlockValidation(unsealedProposal().getSignedPayload())) + .isTrue(); + } + + @Test + public void withTheEnforcementARMEDTheSameProposalISRefused() { + // THE SAME message as above. The only difference is the hook, so a different result means it + // really is called. If this still returned true, the enforcement would be dead code. + final ProposalPayloadValidator validator = + new ProposalPayloadValidator( + proposer, round, null, new PqProposalEnforcement(HEIGHT, new EmptyRegistry(), 2800L)); + assertThat(validator.validateWithoutBlockValidation(unsealedProposal().getSignedPayload())) + .isFalse(); + } + + @Test + public void withTheEnforcementBELOWItsHeightTheSameProposalISValid() { + // The third state, closing the last way of being wrong: a hook that refused regardless of + // height would make the binary impossible to deploy. Here the enforcement exists but does not + // apply yet. + final ProposalPayloadValidator validator = + new ProposalPayloadValidator( + proposer, + round, + null, + new PqProposalEnforcement(HEIGHT + 1, new EmptyRegistry(), 2800L)); + assertThat(validator.validateWithoutBlockValidation(unsealedProposal().getSignedPayload())) + .isTrue(); + } +} diff --git a/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/RoundChangeJustificationPqTest.java b/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/RoundChangeJustificationPqTest.java new file mode 100755 index 000000000..848ad4c09 --- /dev/null +++ b/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/RoundChangeJustificationPqTest.java @@ -0,0 +1,298 @@ +/* + * Copyright contributors to Besu. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.qbft.core.validation; + +import static com.google.common.collect.Iterables.toArray; +import static org.assertj.core.api.Assertions.assertThat; +import static org.hyperledger.besu.consensus.qbft.core.validation.ValidationTestHelpers.createEmptyRoundChangePayloads; +import static org.hyperledger.besu.consensus.qbft.core.validation.ValidationTestHelpers.createPreparePayloads; +import static org.hyperledger.besu.consensus.qbft.core.validation.ValidationTestHelpers.createPreparedCertificate; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.when; + +import org.hyperledger.besu.consensus.common.bft.BftHelpers; +import org.hyperledger.besu.consensus.common.bft.ConsensusRoundHelpers; +import org.hyperledger.besu.consensus.common.bft.ConsensusRoundIdentifier; +import org.hyperledger.besu.consensus.common.bft.blockcreation.ProposerSelector; +import org.hyperledger.besu.consensus.common.bft.payload.SignedData; +import org.hyperledger.besu.consensus.qbft.core.QbftBlockTestFixture; +import org.hyperledger.besu.consensus.qbft.core.messagewrappers.Proposal; +import org.hyperledger.besu.consensus.qbft.core.messagewrappers.RoundChange; +import org.hyperledger.besu.consensus.qbft.core.payload.PreparedRoundMetadata; +import org.hyperledger.besu.consensus.qbft.core.payload.RoundChangePayload; +import org.hyperledger.besu.consensus.qbft.core.statemachine.PreparedCertificate; +import org.hyperledger.besu.consensus.qbft.core.types.QbftBlock; +import org.hyperledger.besu.consensus.qbft.core.types.QbftBlockCodec; +import org.hyperledger.besu.consensus.qbft.core.types.QbftBlockHeader; +import org.hyperledger.besu.consensus.qbft.core.types.QbftBlockInterface; +import org.hyperledger.besu.consensus.qbft.core.types.QbftBlockValidator; +import org.hyperledger.besu.consensus.qbft.core.types.QbftBlockValidator.ValidationResult; +import org.hyperledger.besu.consensus.qbft.core.types.QbftProtocolSchedule; + +import java.util.List; +import java.util.Optional; + +import org.apache.tuweni.bytes.Bytes32; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +/** + * THE COUPLING of round-change justifications to the PREPARE enforcement. + * + *

WHY THIS FILE EXISTS - it is an hour-long scar, from 2026-08-29. The step-6 design asked for a + * SEPARATE gate for justifications, armed later than the PREPARE one, so as not to invalidate "old + * justifications". Two things read in the code overturned that request: + * + *

    + *
  1. a justification cannot be old: {@code validatePrepares} uses + * {@code new ConsensusRoundIdentifier(chainHeight, metadata.getPreparedRound())}, so every + * attached PREPARE is from the height being decided NOW, only from an earlier round; + *
  2. the coupling already exists: {@code RoundChangeMessageValidator} builds a + * {@link PrepareValidator} with the three-argument constructor, and that one wires its own + * enforcement from the system configuration. + *
+ * + *

So a gate armed later would not be a precaution, it would be a BACK DOOR: the same unsealed + * PREPARE, refused when it arrives on its own, would be accepted when it arrives wrapped in a round + * change. The coupling is the security property itself - but until today it followed from an + * implicit constructor and NOTHING guarded it. Anyone "tidying up" that constructor six months from + * now would open the back door without a single test failing. From here on, this one fails. + * + *

WHAT IT DOES NOT PROVE, written down because the gap is visible: it does not prove that a + * justification with VALID seals passes, because the self-wired enforcement uses the live registry + * of the process and a test one cannot be injected along that path. That case is covered by + * {@link PqPrepareEnforcementTest} at the message level and by the network run (F81, scenario A) at + * the chain level. What is proven here is the coupling, in both directions, and that the gate is + * bound to HEIGHT inside the justifications too. + */ +@ExtendWith(MockitoExtension.class) +public class RoundChangeJustificationPqTest { + + @Mock private RoundChangePayloadValidator payloadValidator; + @Mock private QbftProtocolSchedule protocolSchedule; + @Mock private QbftBlockValidator blockValidator; + @Mock private QbftBlockCodec blockEncoder; + @Mock private QbftBlockInterface blockInterface; + @Mock private ProposerSelector proposerSelector; + + private static final int VALIDATOR_COUNT = 4; + private static final int CHAIN_HEIGHT = 3; + + private final ConsensusRoundIdentifier targetRound = + new ConsensusRoundIdentifier(CHAIN_HEIGHT, 3); + private final ConsensusRoundIdentifier roundIdentifier = + ConsensusRoundHelpers.createFrom(targetRound, 0, -1); + + private QbftNodeList validators; + + @BeforeEach + public void setup() { + validators = QbftNodeList.createNodes(VALIDATOR_COUNT, blockEncoder); + lenient().when(protocolSchedule.getBlockValidator(any())).thenReturn(blockValidator); + } + + @AfterEach + public void curata() { + System.clearProperty(PqPrepareEnforcement.PROPERTY_FORK_BLOCK); + } + + private RoundChangeMessageValidator validator() { + return new RoundChangeMessageValidator( + payloadValidator, + BftHelpers.calculateRequiredValidatorQuorum(VALIDATOR_COUNT), + CHAIN_HEIGHT, + validators.getNodeAddresses(), + protocolSchedule); + } + + /** A round change with a prepared block and a justification made of UNSEALED PREPAREs. */ + private RoundChange roundChangeWithUnsealedJustification() { + when(payloadValidator.validate(any())).thenReturn(true); + when(blockValidator.validateBlock(any(), any())) + .thenReturn(new ValidationResult(true, Optional.empty())); + + final QbftBlockHeader header = + new QbftBlockHeaderTestFixture().number(roundIdentifier.getSequenceNumber()).buildHeader(); + final QbftBlock block = new QbftBlockTestFixture().blockHeader(header).build(); + final PreparedCertificate prepCert = + createPreparedCertificate( + block, roundIdentifier, toArray(validators.getNodes(), QbftNode.class)); + return validators.getMessageFactory(0).createRoundChange(targetRound, Optional.of(prepCert)); + } + + // --------------------------------------------------------------------------------------------- + // THE PAIR. The same message, once with the enforcement disarmed and once with it armed. A + // different result means justifications really do pass through the enforcement; the same result + // would mean the back door. + // --------------------------------------------------------------------------------------------- + @Test + public void withoutEnforcementAnUNSEALEDJustificationIsVALID() { + System.clearProperty(PqPrepareEnforcement.PROPERTY_FORK_BLOCK); + assertThat(validator().validate(roundChangeWithUnsealedJustification())).isTrue(); + } + + @Test + public void withTheEnforcementARMEDTheSameJustificationISRefused() { + System.setProperty(PqPrepareEnforcement.PROPERTY_FORK_BLOCK, "0"); + assertThat(validator().validate(roundChangeWithUnsealedJustification())).isFalse(); + } + + // --------------------------------------------------------------------------------------------- + // The third state: the enforcement EXISTS but its height is in the future. Without this test, an + // enforcement that refused regardless of height would pass as correct, and the binary could not + // be rolled onto the fleet before the activation height. + // --------------------------------------------------------------------------------------------- + @Test + public void withTheEnforcementBELOWItsHeightTheJustificationISVALID() { + System.setProperty( + PqPrepareEnforcement.PROPERTY_FORK_BLOCK, Long.toString(CHAIN_HEIGHT + 1L)); + assertThat(validator().validate(roundChangeWithUnsealedJustification())).isTrue(); + } + + // --------------------------------------------------------------------------------------------- + // THE OTHER DIRECTION, and it is the very piece that keeps the way back open: a round change + // WITHOUT a prepared block has no justification to validate, so it never touches the enforcement + // at all. That explains why a chain stalled by the enforcement still advances its rounds + // (measured, finding D-280), and it has to stay true: if it broke, the stall would no longer be + // recoverable along that same road. + // --------------------------------------------------------------------------------------------- + @Test + public void withTheEnforcementARMEDARoundChangeWITHOUTAPreparedBlockPASSES() { + System.setProperty(PqPrepareEnforcement.PROPERTY_FORK_BLOCK, "0"); + when(payloadValidator.validate(any())).thenReturn(true); + for (int i = 0; i < VALIDATOR_COUNT; i++) { + final RoundChange without = + validators.getMessageFactory(i).createRoundChange(targetRound, Optional.empty()); + assertThat(validator().validate(without)).isTrue(); + } + } + + // ============================================================================================= + // THE SECOND PATH, found 2026-08-29 by searching for EVERY place that builds a PrepareValidator + // in production code, not just the one I happened to be looking at. There are three: + // MessageValidator (ordinary PREPAREs), RoundChangeMessageValidator (the justification of a round + // change) and ProposalValidator (the justification of a PROPOSAL for a new round). + // + // Without that search I would have reported "the coupling is guarded" with only one of the two + // justification paths guarded - and the second one is precisely how a prepared block gets + // RE-PROPOSED in a new round. The same back door, a different file. + // ============================================================================================= + + private static final int INALTIME_PROPUNERE = 1; + + private final ConsensusRoundIdentifier roundZero = + new ConsensusRoundIdentifier(INALTIME_PROPUNERE, 0); + private final ConsensusRoundIdentifier roundOne = + new ConsensusRoundIdentifier(INALTIME_PROPUNERE, 1); + + private QbftBlock blocPentru(final ConsensusRoundIdentifier rid, final int autor) { + final QbftBlockHeader h = + new QbftBlockHeaderTestFixture() + .number(rid.getSequenceNumber()) + .coinbase(validators.getNode(autor).getAddress()) + .buildHeader(); + return new QbftBlockTestFixture().blockHeader(h).build(); + } + + private ProposalValidator validatorulPropunerii() { + return new ProposalValidator( + blockInterface, + protocolSchedule, + BftHelpers.calculateRequiredValidatorQuorum(VALIDATOR_COUNT), + validators.getNodeAddresses(), + roundOne, + proposerSelector); + } + + /** + * A round-1 proposal that carries forward a block PREPARED in round 0, with the justification + * made of UNSEALED PREPAREs. The scenario is the upstream one that passes; the only question from + * here on is whether the enforcement changes it. + */ + private Proposal proposalWithUnsealedJustification() { + lenient() + .when(blockValidator.validateBlock(any(), any())) + .thenReturn(new ValidationResult(true, Optional.empty())); + lenient() + .when(proposerSelector.selectProposerForRound(roundZero)) + .thenReturn(validators.getNode(0).getAddress()); + lenient() + .when(proposerSelector.selectProposerForRound(roundOne)) + .thenReturn(validators.getNode(1).getAddress()); + + final QbftBlock blocRundaZero = blocPentru(roundZero, 0); + final QbftBlock blocRundaUnu = blocPentru(roundOne, 1); + + lenient() + .when( + blockInterface.replaceRoundAndProposerForProposalBlock( + blocRundaUnu, 0, validators.getNode(0).getAddress())) + .thenReturn(blocRundaZero); + + final List> schimbari = + createEmptyRoundChangePayloads(roundOne, validators.getNode(0), validators.getNode(1)); + + final RoundChangePayload cuPregatit = + new RoundChangePayload( + roundOne, + Optional.of( + new PreparedRoundMetadata(blocRundaZero.getHash(), roundZero.getRoundNumber()))); + schimbari.add( + SignedData.create( + cuPregatit, + validators + .getNode(2) + .getNodeKey() + .sign(Bytes32.wrap(cuPregatit.hashForSignature().getBytes())))); + + return validators + .getMessageFactory(1) + .createProposal( + roundOne, + blocRundaUnu, + schimbari, + createPreparePayloads( + roundZero, + blocRundaZero.getHash(), + validators.getNode(0), + validators.getNode(1), + validators.getNode(2))); + } + + @Test + public void withoutEnforcementAPROPOSALWithAnUNSEALEDJustificationIsVALID() { + System.clearProperty(PqPrepareEnforcement.PROPERTY_FORK_BLOCK); + assertThat(validatorulPropunerii().validate(proposalWithUnsealedJustification())).isTrue(); + } + + @Test + public void withTheEnforcementARMEDTheSamePROPOSALISRefused() { + System.setProperty(PqPrepareEnforcement.PROPERTY_FORK_BLOCK, "0"); + assertThat(validatorulPropunerii().validate(proposalWithUnsealedJustification())).isFalse(); + } + + @Test + public void withTheEnforcementBELOWItsHeightThePROPOSALISVALID() { + System.setProperty( + PqPrepareEnforcement.PROPERTY_FORK_BLOCK, Long.toString(INALTIME_PROPUNERE + 1L)); + assertThat(validatorulPropunerii().validate(proposalWithUnsealedJustification())).isTrue(); + } +} diff --git a/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/RoundChangePayloadValidatorPqWiringTest.java b/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/RoundChangePayloadValidatorPqWiringTest.java new file mode 100755 index 000000000..b906ec016 --- /dev/null +++ b/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/RoundChangePayloadValidatorPqWiringTest.java @@ -0,0 +1,125 @@ +/* + * Copyright contributors to Besu. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.qbft.core.validation; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.hyperledger.besu.consensus.common.bft.ConsensusRoundIdentifier; +import org.hyperledger.besu.consensus.common.bft.PqSignerRegistry; +import org.hyperledger.besu.consensus.qbft.core.messagewrappers.RoundChange; +import org.hyperledger.besu.consensus.qbft.core.payload.MessageFactory; +import org.hyperledger.besu.consensus.qbft.core.types.QbftBlockCodec; +import org.hyperledger.besu.cryptoservices.NodeKey; +import org.hyperledger.besu.cryptoservices.NodeKeyUtils; +import org.hyperledger.besu.datatypes.Address; +import org.hyperledger.besu.ethereum.core.Util; + +import java.util.List; +import java.util.Optional; + +import org.apache.tuweni.bytes.Bytes; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +/** + * THE WIRING, not the class: does {@link RoundChangePayloadValidator} actually CALL the + * enforcement? + * + *

Same reason the PREPARE and PROPOSAL twins exist: a class present in the binary, environment + * variables visible to the process, and code that never runs is exactly the failure paid for on + * 2026-08-28. The registry here REFUSES everything, so this does not measure cryptography (that has + * its own test), only whether the decision passes through the hook. The pair is all it takes: the + * same signed round-change, once with enforcement and once without. + */ +@ExtendWith(MockitoExtension.class) +public class RoundChangePayloadValidatorPqWiringTest { + + private static final long HEIGHT = 1L; + + // A round-change must target a POSITIVE round, so the identifier moves to round 1. + private final ConsensusRoundIdentifier targetRound = new ConsensusRoundIdentifier((int) HEIGHT, 1); + @Mock private QbftBlockCodec blockEncoder; + + private final NodeKey nodeKey = NodeKeyUtils.generate(); + private final Address author = Util.publicKeyToAddress(nodeKey.getPublicKey()); + private MessageFactory messageFactory; + + @BeforeEach + public void setup() { + messageFactory = new MessageFactory(nodeKey, blockEncoder); + } + + /** A registry that binds no index and verifies nothing. */ + private static final class EmptyRegistry implements PqSignerRegistry { + @Override + public Address addressForIndexAtHistoric(final long blockNumber, final int validatorIndex) { + return null; + } + + @Override + public Address addressForIndexAtOwnHead(final long blockNumber, final int validatorIndex) { + return null; + } + + @Override + public boolean verifyAtHistoric( + final long blockNumber, final int validatorIndex, final Bytes message, final Bytes signature) { + return false; + } + + @Override + public boolean verifyAtOwnHead( + final long blockNumber, final int validatorIndex, final Bytes message, final Bytes signature) { + return false; + } + } + + private RoundChange unsealedRoundChange() { + return messageFactory.createRoundChange(targetRound, Optional.empty()); + } + + @Test + public void withoutEnforcementAnUnsealedRoundChangeISValid() { + final RoundChangePayloadValidator validator = + new RoundChangePayloadValidator(List.of(author), HEIGHT, null); + assertThat(validator.validate(unsealedRoundChange().getSignedPayload())).isTrue(); + } + + @Test + public void withTheEnforcementARMEDTheSameRoundChangeISRefused() { + // THE SAME message as above. The only difference is the hook, so a different result means it + // really is called. If this still returned true, the enforcement would be dead code. + final RoundChangePayloadValidator validator = + new RoundChangePayloadValidator( + List.of(author), HEIGHT, new PqRoundChangeEnforcement(HEIGHT, new EmptyRegistry(), 2800L)); + assertThat(validator.validate(unsealedRoundChange().getSignedPayload())).isFalse(); + } + + @Test + public void withTheEnforcementBELOWItsHeightTheSameRoundChangeISValid() { + // The third state, closing the last way of being wrong: a hook that refused regardless of + // height would make the binary impossible to deploy. Here the enforcement exists but does not + // apply yet. + final RoundChangePayloadValidator validator = + new RoundChangePayloadValidator( + List.of(author), + HEIGHT, + new PqRoundChangeEnforcement(HEIGHT + 1, new EmptyRegistry(), 2800L)); + assertThat(validator.validate(unsealedRoundChange().getSignedPayload())).isTrue(); + } +} diff --git a/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/QbftBlockHeaderValidationRulesetFactory.java b/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/QbftBlockHeaderValidationRulesetFactory.java index f54d7bfcd..cfc67a900 100644 --- a/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/QbftBlockHeaderValidationRulesetFactory.java +++ b/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/QbftBlockHeaderValidationRulesetFactory.java @@ -11,6 +11,12 @@ * specific language governing permissions and limitations under the License. * * SPDX-License-Identifier: Apache-2.0 + * + * Modifications Copyright contributors to the Aere Network. + * + * This file was modified by contributors to the Aere Network, as required by section 4(b) of the + * Apache License 2.0. The copyright header above is the upstream one and is left exactly as it was + * found, as section 4(c) requires. The change itself is in patches/0003-aere-pq-anchor.patch. */ package org.hyperledger.besu.consensus.qbft; @@ -18,8 +24,17 @@ import static org.hyperledger.besu.ethereum.mainnet.AbstractGasLimitSpecificatio import static org.hyperledger.besu.ethereum.mainnet.AbstractGasLimitSpecification.DEFAULT_MIN_GAS_LIMIT; import org.hyperledger.besu.consensus.common.bft.BftHelpers; +import org.hyperledger.besu.consensus.common.bft.FalconSealSupport; +import org.hyperledger.besu.consensus.common.bft.PqAnchorConfig; import org.hyperledger.besu.consensus.common.bft.headervalidationrules.BftCoinbaseValidationRule; import org.hyperledger.besu.consensus.common.bft.headervalidationrules.BftCommitSealsValidationRule; +import org.hyperledger.besu.consensus.qbft.headervalidationrules.AereBaseFeeImportRule; +import org.hyperledger.besu.consensus.qbft.headervalidationrules.FalconSealValidationRule; +import org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorDigestAttachedRule; +import org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorDigestRule; +import org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorSealsRule; +import org.hyperledger.besu.consensus.qbft.headervalidationrules.PqEmergencyShoutRule; +import org.hyperledger.besu.consensus.qbft.headervalidationrules.PqRegistryBindingRule; import org.hyperledger.besu.consensus.qbft.headervalidationrules.QbftValidatorsValidationRule; import org.hyperledger.besu.ethereum.core.BlockHeader; import org.hyperledger.besu.ethereum.mainnet.BlockHeaderValidator; @@ -54,14 +69,90 @@ public class QbftBlockHeaderValidationRulesetFactory { final Duration minimumTimeBetweenBlocks, final boolean useValidatorContract, final Optional baseFeeMarket) { + return blockHeaderValidator( + minimumTimeBetweenBlocks, + useValidatorContract, + baseFeeMarket, + PqAnchorConfig.fromSystemConfiguration()); + } + + /** + * Produces a BlockHeaderValidator configured for assessing bft block headers, with an explicit V2 + * certificate-anchor configuration. + * + *

AERE ANCORA-V2 (2026-08-01). Two rules are added and one is retired, all indexed on the BLOCK + * NUMBER: + * + *

    + *
  • {@link PqAnchorDigestRule} (R1), DETACHED and part of LIGHT validation: recomputes the + * anchor digest D from the header's own parent hash, its own height minus one, the chain id + * and the certificate the header itself carries, and requires it to equal vanityData. One + * keccak, no state. This is what makes a stripped or swapped certificate detectable, and + * detectable on a node that is fast-syncing headers. + *
  • {@link PqAnchorSealsRule} (R2), ATTACHED and full validation only: threshold, strictly + * increasing indices, eligibility in the PARENT's validator set, and k Falcon verifications + * over M(parent). + *
  • {@link FalconSealValidationRule} retires at the same height. It is replaced, not promoted: + * it is excluded from light validation, it returns true on every path including its + * exception path, and it reads historical world state over a window that does not exist on a + * syncing node. + *
+ * + *

Below the activation height H the two new rules return true as their FIRST statement, before + * any decode, and the legacy rule behaves exactly as before. A binary built from this code is + * therefore bit-for-bit equivalent to today's on the whole existing chain, which is the condition + * for warming it on a live node. + * + *

The rule COUNT goes from 11 to 16 (A8 adds the registry-binding rule, was 13; SINCRONIZARE + * adds the attached copy of the digest rule, was 14; OPTIUNI-URGENTA adds the emergency + * announcement rule, which can never reject, was 15) (14 unconditional plus the conditional timestamp rule). The + * design note said 12 because it assumed the legacy Falcon rule would be deleted; retiring it by + * height instead is what keeps behaviour below H identical, so it stays in the list. + * + * @param minimumTimeBetweenBlocks the minimum amount of time that must elapse between blocks. + * @param useValidatorContract whether validator selection is using a validator contract + * @param baseFeeMarket an {@link Optional} wrapping {@link BaseFeeMarket} class if appropriate. + * @param pqAnchorConfig the height-indexed V2 certificate-anchor configuration + * @return BlockHeaderValidator configured for assessing bft block headers + */ + public static BlockHeaderValidator.Builder blockHeaderValidator( + final Duration minimumTimeBetweenBlocks, + final boolean useValidatorContract, + final Optional baseFeeMarket, + final PqAnchorConfig pqAnchorConfig) { + // AERE FIX-OPRIRE-CONSENS (b): force EAGER initialisation of FalconSealSupport here, while the + // protocol schedule is being built at node startup and before the network is up. The class runs + // its fail-closed configuration guards in its constructor; leaving it lazily initialised meant a + // node with an unsafe Falcon activation config started normally and only discovered the problem + // the first time a QBFT round touched the singleton, i.e. ON the consensus path. Touching it + // here moves every refusal back to config time, which is the only safe place for it. + FalconSealSupport.instance(); + BlockHeaderValidator.Builder ruleBuilder = new BlockHeaderValidator.Builder() + // AERE OPTIUNI-URGENTA (2026-08-02): FIRST, and it can never reject. Its whole job is + // to make an emergency override impossible to run on without noticing, by writing one + // complete ERROR line at every height for as long as one is in force. First in the list + // because a rule that always returns true cannot change the verdict of a conjunction, + // and because the moment an operator most needs to know a control was overridden is + // when a LATER rule is rejecting headers. + .addRule(new PqEmergencyShoutRule(pqAnchorConfig)) .addRule(new AncestryValidationRule()) .addRule(new GasUsageValidationRule()) .addRule( new GasLimitRangeAndDeltaValidationRule( DEFAULT_MIN_GAS_LIMIT, DEFAULT_MAX_GAS_LIMIT, baseFeeMarket)) .addRule(new TimestampBoundedByFutureParameter(1)) + // AERE D-AMONTE-02: the base-fee rule every non-BFT factory in this jar wires and the + // QBFT one upstream forgot. Height-gated (disarmed = today's behaviour, byte for byte): + // the chain's HISTORY contains blocks this validation would reject (the two floor-less + // days, the lost-threshold window where the fee was not a function of the parent), so + // it must never look below its arming height. Delegates to THIS node's fee market, so + // the AERE 1 Gwei floor is validated too - the check that closes the empty-block + // one-wei divergence measured on the mixed network (STARE-PRODUCATOR 1bis). + .addRule( + new AereBaseFeeImportRule( + AereBaseFeeImportRule.armedFromSystemConfig(), baseFeeMarket)) .addRule( new ConstantFieldValidationRule<>( "MixHash", BlockHeader::getMixHash, BftHelpers.EXPECTED_MIX_HASH)) @@ -70,7 +161,34 @@ public class QbftBlockHeaderValidationRulesetFactory { "Difficulty", BlockHeader::getDifficulty, UInt256.ONE)) .addRule(new QbftValidatorsValidationRule(useValidatorContract)) .addRule(new BftCoinbaseValidationRule()) - .addRule(new BftCommitSealsValidationRule()); + .addRule(new BftCommitSealsValidationRule()) + // AERE ANCORA-V2: the legacy log-only Falcon rule now stands down at H. With an + // unconfigured anchor this is Long.MAX_VALUE, i.e. it never stands down and behaviour is + // unchanged. + .addRule( + new FalconSealValidationRule(pqAnchorConfig.legacyFalconRuleRetirementBlock())) + // AERE ANCORA-V2 R1: cheap, detached, IN LIGHT VALIDATION. Ordered before R2 so the one + // keccak that binds the certificate to the block hash runs before k Falcon + // verifications are spent on it. + .addRule(new PqAnchorDigestRule(pqAnchorConfig)) + // AERE SINCRONIZARE R1A (2026-08-02): the SAME digest verdict, on the ATTACHED side. + // Measured cause it repairs: SKIP_DETACHED, the mode FullImportBlockStep passes to + // importBlock for every block acquired by sync, keeps exactly the rules that are NOT + // detached, so R1 does not run at import; and DETACHED_ONLY, the mode under which R1 + // does run, is only applied by DownloadHeaderSequenceTask, i.e. only on ranges with a + // closed end. The open-ended range at the tip of a recovery, 199 blocks by default, was + // therefore checked by neither. Ordered immediately before R2 so the one keccak still + // runs before k Falcon verifications are spent. Delegates to R1, so there is one + // definition of the digest and the two paths cannot drift. + .addRule(new PqAnchorDigestAttachedRule(pqAnchorConfig)) + // AERE ANCORA-V2 R2: attached, full validation only. + .addRule(new PqAnchorSealsRule(pqAnchorConfig)) + // AERE A8 per-block half: the registry this node runs must be the registry + // config.pqRegistryHash requires AT THIS HEIGHT. The startup guard answers + // that once, against the head that existed at startup; a rotation entry in + // the schedule can pass underneath a running node and never be noticed. + // Inert when no schedule is configured, which is chain 2800 today. + .addRule(new PqRegistryBindingRule()); // Currently the minimum acceptable time between blocks is 1 second. The timestamp of an // Ethereum header is stored as seconds since Unix epoch so blocks being produced more diff --git a/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/QbftExtraDataCodec.java b/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/QbftExtraDataCodec.java index 39c7aa300..b3096d331 100644 --- a/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/QbftExtraDataCodec.java +++ b/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/QbftExtraDataCodec.java @@ -11,6 +11,12 @@ * specific language governing permissions and limitations under the License. * * SPDX-License-Identifier: Apache-2.0 + * + * Modifications Copyright contributors to the Aere Network. + * + * This file was modified by contributors to the Aere Network, as required by section 4(b) of the + * Apache License 2.0. The copyright header above is the upstream one and is left exactly as it was + * found, as section 4(c) requires. The change itself is in patches/0003-aere-pq-anchor.patch. */ package org.hyperledger.besu.consensus.qbft; @@ -19,6 +25,9 @@ import static org.hyperledger.besu.consensus.common.bft.Vote.DROP_BYTE_VALUE; import org.hyperledger.besu.consensus.common.bft.BftExtraData; import org.hyperledger.besu.consensus.common.bft.BftExtraDataCodec; +import org.hyperledger.besu.consensus.common.bft.FalconSeal; +import org.hyperledger.besu.consensus.common.bft.PqAnchorV2; +import org.hyperledger.besu.consensus.common.bft.SchemeSeal; import org.hyperledger.besu.consensus.common.bft.Vote; import org.hyperledger.besu.consensus.common.validator.VoteType; import org.hyperledger.besu.crypto.SECPSignature; @@ -41,6 +50,15 @@ import org.apache.tuweni.bytes.Bytes; /** * Represents the data structure stored in the extraData field of the BlockHeader used when * operating under an BFT consensus mechanism. + * + *

AERE hybrid PQC extension: an OPTIONAL, PARALLEL Falcon-512 seal list (a per-block post-quantum + * quorum certificate) is appended after the ECDSA committed-seal list, but ONLY in the {@link + * EncodingType#ALL} (stored-header) encoding AND only when at least one Falcon seal is present. The + * two "exclude" encodings used for the committed-seal hash and the on-chain block hash omit the + * Falcon list entirely, and a header with no Falcon seals omits it too, so in every case the bytes + * are identical to upstream Besu whenever no post-quantum certificate is carried. This guarantees + * the Falcon seals never disturb the ECDSA committed seals or any signed pre-image. Decoding is + * backward-tolerant: headers with no Falcon list decode to an empty Falcon-seal collection. */ public class QbftExtraDataCodec extends BftExtraDataCodec { private static final ImmutableBiMap voteToValue = @@ -102,9 +120,91 @@ public class QbftExtraDataCodec extends BftExtraDataCodec { final List seals = rlpInput.readList( rlp -> SignatureAlgorithmFactory.getInstance().decodeSignature(rlp.readBytes())); - rlpInput.leaveListLenient(); - return new BftExtraData(vanityData, seals, vote, round, validators); + // AERE hybrid PQC: an OPTIONAL parallel Falcon-512 seal list may follow the ECDSA seals. + // Backward-tolerant: older/exclude/no-certificate encodings without this element decode to an + // empty list. + final List falconSeals; + final List hybridSeals; + if (!rlpInput.isEndOfCurrentList()) { + // AERE ANCHOR V2 (2026-09-03): the sixth element is EITHER the v1 Falcon list + // RLP[[idx,sig],...] OR the scheme-tagged v2 certificate RLP[2,[[scheme,idx,sig],...]]. + // They cannot be confused: v1 opens with a LIST, v2 with the SCALAR 2. The raw bytes are + // taken first so the v2 decoder can enforce its canonical round trip on exactly what the + // header carried; the codec's own round trip (gate 2 below) then covers both forms. + final Bytes certificateRaw = rlpInput.readAsRlp().raw(); + final RLPInput probe = new BytesValueRLPInput(certificateRaw, false); + probe.enterList(); + final boolean v2 = !probe.isEndOfCurrentList() && !probe.nextIsList(); + if (v2) { + falconSeals = Collections.emptyList(); + try { + hybridSeals = PqAnchorV2.decode(certificateRaw); + } catch (final RuntimeException e) { + throw new RLPException( + "AERE ANCHOR V2: the header's certificate does not decode as a canonical v2" + + " certificate: " + + e.getMessage()); + } + } else { + hybridSeals = Collections.emptyList(); + falconSeals = + new BytesValueRLPInput(certificateRaw, false) + .readList( + rlp -> { + rlp.enterList(); + final int idx = rlp.readIntScalar(); + final Bytes sig = rlp.readBytes(); + rlp.leaveList(); + return new FalconSeal(idx, sig); + }); + } + } else { + falconSeals = Collections.emptyList(); + hybridSeals = Collections.emptyList(); + } + + // AERE FIX-MALEABILITATE-BLOC (2026-08-01), gate 1 of 2: the leave is STRICT. + // + // Upstream ends this decode with leaveListLenient(), which silently discards anything still + // left in the outer list. That is not survivable here, because the QBFT block hash and the + // committed-seal pre-image are BOTH computed from a RE-ENCODE of the decoded BftExtraData + // (BftBlockHashing.calculateHashOfBftBlockOnchain line 84-91, calculateDataHashForCommittedSeal + // line 53-75). Anything the decoder discards is therefore dropped before hashing, so two + // different header byte strings carry the same block hash and the same genuine validator + // seals. leaveList() rejects exactly the inputs that have content left over, and is + // byte-for-byte identical to the lenient leave on every input that has not. + rlpInput.leaveList(); + + final BftExtraData decoded = + new BftExtraData(vanityData, seals, vote, round, validators, falconSeals, hybridSeals); + + // AERE FIX-MALEABILITATE-BLOC (2026-08-01), gate 2 of 2: CANONICAL ROUND TRIP. + // + // The strict leave alone does not close the hole. The Falcon element is OPTIONAL on the wire + // and the encoder omits it when the certificate is empty, so a header carrying an explicit + // EMPTY Falcon list decodes to exactly the same value as a header carrying no Falcon element + // at all: the trailing 0xc0 is CONSUMED, not left over, so no leave, strict or lenient, can + // see it. Requiring the full re-encode to reproduce the received bytes makes the accepted + // encoding unique by construction, and closes every present and future shape of this defect + // rather than the one instance of it we happened to look for. + // + // Backward compatibility: the re-encode used here is the same EncodingType.ALL the block + // producer uses, so every header any Aere or stock Besu node has ever produced round trips + // unchanged. Headers with no Falcon element re-encode with no Falcon element, which is why + // the whole pre-Falcon history of the chain still decodes. + final Bytes canonical = encode(decoded, EncodingType.ALL); + if (!canonical.equals(input)) { + throw new RLPException( + "Non-canonical BFT extra data: the header's extraData is " + + input.size() + + " bytes but its canonical re-encoding is " + + canonical.size() + + " bytes. A QBFT block hash is computed over the re-encoded form, so a non-canonical" + + " encoding would give two header byte strings one block hash. Rejected."); + } + + return decoded; } @Override @@ -126,6 +226,24 @@ public class QbftExtraDataCodec extends BftExtraDataCodec { if (encodingType != EncodingType.EXCLUDE_COMMIT_SEALS) { encoder.writeList( bftExtraData.getSeals(), (committer, rlp) -> rlp.writeBytes(committer.encodedBytes())); + // AERE hybrid PQC: only the full (stored-header) encoding carries the parallel Falcon + // quorum certificate, and only when it is non-empty. The exclude encodings omit it so the + // committed-seal hash and the on-chain block hash are byte-identical to upstream Besu, and a + // header with no certificate is byte-identical to upstream too, so the Falcon seals are + // never part of any signed pre-image and never disturb the block hash. + if (!bftExtraData.getHybridSeals().isEmpty()) { + // AERE ANCHOR V2: the scheme-tagged certificate, canonical bytes, in the same slot. + encoder.writeRaw(PqAnchorV2.encode(bftExtraData.getHybridSeals())); + } else if (!bftExtraData.getFalconSeals().isEmpty()) { + encoder.writeList( + bftExtraData.getFalconSeals(), + (falconSeal, rlp) -> { + rlp.startList(); + rlp.writeIntScalar(falconSeal.getValidatorIndex()); + rlp.writeBytes(falconSeal.getSignature()); + rlp.endList(); + }); + } } else { encoder.writeEmptyList(); } diff --git a/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/adaptor/QbftBlockCreatorAdaptor.java b/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/adaptor/QbftBlockCreatorAdaptor.java index 594fe83e4..dd67707b1 100644 --- a/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/adaptor/QbftBlockCreatorAdaptor.java +++ b/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/adaptor/QbftBlockCreatorAdaptor.java @@ -11,26 +11,51 @@ * specific language governing permissions and limitations under the License. * * SPDX-License-Identifier: Apache-2.0 + * + * Modifications Copyright contributors to the Aere Network. + * + * This file was modified by contributors to the Aere Network, as required by section 4(b) of the + * Apache License 2.0. The copyright header above is the upstream one and is left exactly as it was + * found, as section 4(c) requires. The change itself is in patches/0003-aere-pq-anchor.patch. */ package org.hyperledger.besu.consensus.qbft.adaptor; +import org.hyperledger.besu.consensus.common.bft.BftBlockHashing; import org.hyperledger.besu.consensus.common.bft.BftBlockHeaderFunctions; import org.hyperledger.besu.consensus.common.bft.BftExtraData; import org.hyperledger.besu.consensus.common.bft.BftExtraDataCodec; +import org.hyperledger.besu.consensus.common.bft.FalconSeal; +import org.hyperledger.besu.consensus.common.bft.FalconSealSupport; +import org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer; import org.hyperledger.besu.consensus.qbft.core.types.QbftBlock; import org.hyperledger.besu.consensus.qbft.core.types.QbftBlockCreator; import org.hyperledger.besu.consensus.qbft.core.types.QbftBlockHeader; import org.hyperledger.besu.crypto.SECPSignature; +import org.hyperledger.besu.datatypes.Address; +import org.hyperledger.besu.datatypes.Hash; import org.hyperledger.besu.ethereum.blockcreation.BlockCreator; import org.hyperledger.besu.ethereum.core.Block; import org.hyperledger.besu.ethereum.core.BlockHeader; import org.hyperledger.besu.ethereum.core.BlockHeaderBuilder; +import java.util.ArrayList; import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Optional; +import java.util.OptionalInt; +import java.util.Set; + +import org.apache.tuweni.bytes.Bytes32; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** Adaptor class to allow a {@link BlockCreator} to be used as a {@link QbftBlockCreator}. */ public class QbftBlockCreatorAdaptor implements QbftBlockCreator { + private static final Logger LOG = LoggerFactory.getLogger(QbftBlockCreatorAdaptor.class); + private final BlockCreator besuBlockCreator; private final BftExtraDataCodec bftExtraDataCodec; @@ -59,11 +84,60 @@ public class QbftBlockCreatorAdaptor implements QbftBlockCreator { @Override public QbftBlock createSealedBlock( final QbftBlock block, final int roundNumber, final Collection commitSeals) { + return createSealedBlock(block, roundNumber, commitSeals, Collections.emptyList()); + } + + @Override + public QbftBlock createSealedBlock( + final QbftBlock block, + final int roundNumber, + final Collection commitSeals, + final Collection collectedFalconSeals) { final Block besuBlock = AdaptorUtil.toBesuBlock(block); final QbftBlockHeader initialHeader = block.getHeader(); final BftExtraData initialExtraData = bftExtraDataCodec.decode(AdaptorUtil.toBesuBlockHeader(initialHeader)); + // AERE ANCHOR V2, THE MEASURED TRAP, CLOSED HERE. Above the activation height element 6 is no + // longer "the seals I heard for THIS block": it is the quorum certificate over the PARENT, + // CHOSEN BY THE PROPOSER, and vanityData carries its digest D. Every node that seals this block + // must therefore carry the proposer's bytes across UNCHANGED. The legacy assembly below would + // overwrite them with what this node heard for the current block, which would leave a header + // whose vanity D no longer matches its own element 6, i.e. a header this node's own digest rule + // rejects. So above the activation height the assembler is skipped outright. + // + // Note what is NOT done here: no re-verification, no re-selection, no comparison against what + // this node heard. That is the whole point of V2. The proposer's certificate has already been + // checked by the header rules on the proposal, and re-deriving it locally is exactly the + // dependency on "what I heard" that killed the earlier design. + if (PqAnchorProducer.config().activeAt(initialHeader.getNumber())) { + final BftExtraData anchoredExtraData = + new BftExtraData( + initialExtraData.getVanityData(), + commitSeals, + initialExtraData.getVote(), + roundNumber, + initialExtraData.getValidators(), + initialExtraData.getFalconSeals(), + // AERE ANCHOR V2 (2026-09-03, D-328): the sealed block keeps the proposer's v2 certificate + // verbatim, exactly as it keeps the v1 one; dropping it here is what stalled the testnet. + initialExtraData.getHybridSeals()); + final BlockHeader anchoredHeader = + BlockHeaderBuilder.fromHeader(AdaptorUtil.toBesuBlockHeader(initialHeader)) + .extraData(bftExtraDataCodec.encode(anchoredExtraData)) + .blockHeaderFunctions(BftBlockHeaderFunctions.forOnchainBlock(bftExtraDataCodec)) + .buildBlockHeader(); + LOG.debug( + "AERE PQ-ANCHOR: sealed block {} keeping the proposer's {}-seal parent certificate " + + "verbatim ({} seal(s) heard locally for this block were deliberately ignored)", + anchoredHeader.getNumber(), + initialExtraData.getFalconSeals().size(), + collectedFalconSeals == null ? 0 : collectedFalconSeals.size()); + return new QbftBlockAdaptor(new Block(anchoredHeader, besuBlock.getBody())); + } + + // Step 1: build the sealed header exactly as upstream, with the decisive ECDSA committed seals + // and (for now) no Falcon seals. The ECDSA path is byte-for-byte unchanged. final BftExtraData sealedExtraData = new BftExtraData( initialExtraData.getVanityData(), @@ -72,12 +146,149 @@ public class QbftBlockCreatorAdaptor implements QbftBlockCreator { roundNumber, initialExtraData.getValidators()); - final BlockHeader sealedHeader = + BlockHeader sealedHeader = BlockHeaderBuilder.fromHeader(AdaptorUtil.toBesuBlockHeader(initialHeader)) .extraData(bftExtraDataCodec.encode(sealedExtraData)) .blockHeaderFunctions(BftBlockHeaderFunctions.forOnchainBlock(bftExtraDataCodec)) .buildBlockHeader(); + + // Step 2 (AERE hybrid PQC): assemble a parallel Falcon-512 QUORUM CERTIFICATE from the seals + // gossiped on the commit messages. Each seal is re-verified against the registry over the SAME + // commit hash the ECDSA committed seal signs, and de-duplicated by validator index, so the + // embedded certificate is exactly the set of distinct, valid post-quantum seals. Falcon seals + // are excluded from every signed pre-image (committed-seal hash and on-chain block hash), so the + // block hash is identical whether or not the certificate is present. Any failure here leaves the + // ECDSA-sealed header untouched and valid. + try { + final FalconSealSupport pqc = FalconSealSupport.instance(); + final Hash commitHash = + new BftBlockHashing(bftExtraDataCodec).calculateDataHashForCommittedSeal(sealedHeader); + + // AERE audit fix (AUD-CONSENSUS-1 / -2): restrict the embedded certificate to ELIGIBLE + // signers (current validators carried in extraData INTERSECT the address-bound registry), so + // the assembler never embeds a seal the header rule would later reject as ineligible. + final Set

registered = pqc.registeredValidatorAddresses(); + final Set
eligible = new LinkedHashSet<>(); + for (final Address v : initialExtraData.getValidators()) { + if (registered.contains(v)) { + eligible.add(v); + } + } + + // AERE DISC 2026-08-08: the interval gate. Measured on chain 2800 the same day: with all + // seven validators attaching, this assembler wrote FIVE seals into EVERY header, 525 -> 3844 + // bytes, about 200 GB per node per year against 12 GB free on the tightest host. + // + // The anchor producer has had an interval and a cap since 7 August. This assembler, the one + // that runs BEFORE the activation height, had neither, so the controls were unreachable + // exactly during the window they were needed. Skipping the whole attach block, rather than + // assembling and then discarding, also saves the Falcon verification of every heard seal. + if (!pqc.attachesCertificateAt(sealedHeader.getNumber())) { + LOG.debug( + "AERE PQC: block {} is not an attachment height (interval {}), leaving the header " + + "ECDSA-only at {} bytes", + sealedHeader.getNumber(), + pqc.attachInterval().isPresent() ? pqc.attachInterval().getAsInt() : 1, + sealedHeader.getExtraData().size()); + return new QbftBlockAdaptor(new Block(sealedHeader, besuBlock.getBody())); + } + + final List quorumCert = + verifiedDistinctSeals( + pqc, + sealedHeader.getNumber(), + commitHash, + collectedFalconSeals, + eligible, + pqc.attachMaxSeals()); + + // Fallback self-seal: if nothing was gossiped (e.g. the pre-gossip 3-arg path) but this node + // can sign AND is itself an eligible signer, attach its own seal so a single-signer + // certificate is still produced. + if (quorumCert.isEmpty() && pqc.signingEnabled()) { + // D2 (2026-08-06): this is the ONE registry question in the stack with no honest height - + // "am I, right now, an eligible signer", asked before signing with the single private key + // this process holds. It gets its own name rather than a fabricated height, so that no + // future reader mistakes it for a verification path. See FalconSealSupport#localSigningAddress. + final Address self = pqc.localSigningAddress(); + if (self != null && eligible.contains(self)) { + // AERE FIX-OPRIRE-CONSENS (b): height-gated like every other attachment point. + final Optional selfSeal = + pqc.sign(sealedHeader.getNumber(), Bytes32.wrap(commitHash.getBytes())); + selfSeal.ifPresent(quorumCert::add); + } + } + + if (!quorumCert.isEmpty()) { + final BftExtraData hybridExtraData = + new BftExtraData( + initialExtraData.getVanityData(), + commitSeals, + initialExtraData.getVote(), + roundNumber, + initialExtraData.getValidators(), + quorumCert); + sealedHeader = + BlockHeaderBuilder.fromHeader(AdaptorUtil.toBesuBlockHeader(initialHeader)) + .extraData(bftExtraDataCodec.encode(hybridExtraData)) + .blockHeaderFunctions(BftBlockHeaderFunctions.forOnchainBlock(bftExtraDataCodec)) + .buildBlockHeader(); + LOG.debug( + "AERE PQC: attached Falcon-512 quorum certificate ({} distinct valid seal(s)) to block {}", + quorumCert.size(), + sealedHeader.getNumber()); + } + } catch (final RuntimeException e) { + LOG.warn( + "AERE PQC: failed to attach Falcon quorum certificate; keeping ECDSA-only sealed block " + + "(block still valid and final): {}", + e.toString()); + } + final Block sealedBesuBlock = new Block(sealedHeader, besuBlock.getBody()); return new QbftBlockAdaptor(sealedBesuBlock); } + + // D2 (2026-08-06): takes the height of the block being sealed. The seals gathered here are over + // THIS block's committed-seal hash, so the height is this block's own and is known at the call + // site. It matters at exactly one moment - a rotation height - where assembling a certificate + // under the head key set while every validator checks it under the scheduled one produces a block + // the fleet rejects, with nothing in any log naming the reason. + private static List verifiedDistinctSeals( + final FalconSealSupport pqc, + final long blockNumber, + final Hash commitHash, + final Collection seals, + final Set
eligible, + final OptionalInt cap) { + final List out = new ArrayList<>(); + final Set
seen = new LinkedHashSet<>(); + final int limit = cap.orElse(Integer.MAX_VALUE); + for (final FalconSeal seal : seals) { + // The cap is read HERE, at the top of each turn, so it counts only seals that have already + // survived eligibility and Falcon verification below. Placed before verification it would + // stop at the cap while holding seals that turn out to be bad, and a short certificate made + // of unchecked seals is worse than a long one. Same ordering, and same reason, as the anchor + // producer's own cap. + if (out.size() >= limit) { + break; + } + if (seal == null) { + continue; + } + // Bind each seal to its registered validator address and keep it only if that address is an + // eligible signer (a current validator with a registered key), de-duplicated by address. + // D2 (b-v2): the OWN-HEAD door. This is the block this node is sealing right now. + final Address signer = pqc.addressForIndexAtOwnHead(blockNumber, seal.getValidatorIndex()); + if (signer == null || !eligible.contains(signer) || seen.contains(signer)) { + continue; + } + if (pqc.verifyAtOwnHead( + blockNumber, seal.getValidatorIndex(), commitHash.getBytes(), seal.getSignature())) { + seen.add(signer); + out.add(seal); + } + } + return out; + } } diff --git a/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/blockcreation/QbftBlockCreatorFactory.java b/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/blockcreation/QbftBlockCreatorFactory.java index 5e6de291b..cabeba99c 100644 --- a/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/blockcreation/QbftBlockCreatorFactory.java +++ b/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/blockcreation/QbftBlockCreatorFactory.java @@ -11,6 +11,12 @@ * specific language governing permissions and limitations under the License. * * SPDX-License-Identifier: Apache-2.0 + * + * Modifications Copyright contributors to the Aere Network. + * + * This file was modified by contributors to the Aere Network, as required by section 4(b) of the + * Apache License 2.0. The copyright header above is the upstream one and is left exactly as it was + * found, as section 4(c) requires. The change itself is in patches/0003-aere-pq-anchor.patch. */ package org.hyperledger.besu.consensus.qbft.blockcreation; @@ -20,6 +26,7 @@ import org.hyperledger.besu.consensus.common.ForksSchedule; import org.hyperledger.besu.consensus.common.bft.BftExtraData; import org.hyperledger.besu.consensus.common.bft.BftExtraDataCodec; import org.hyperledger.besu.consensus.common.bft.blockcreation.BftBlockCreatorFactory; +import org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer; import org.hyperledger.besu.datatypes.Address; import org.hyperledger.besu.ethereum.ProtocolContext; import org.hyperledger.besu.ethereum.core.BlockHeader; @@ -67,14 +74,34 @@ public class QbftBlockCreatorFactory extends BftBlockCreatorFactoryBoth paths are covered on purpose. The validator-contract path builds its own extra data and + * does NOT go through the shared base method, so a change made only in the base class would leave + * that path writing the old vanity and no certificate, which post-fork means every block it + * proposes is rejected. This is the second of the two measured traps in the producer chain; the + * other is in {@code QbftBlockCreatorAdaptor.createSealedBlock}. + * + *

Below the activation height {@link PqAnchorProducer#apply} returns its input unchanged, so + * this method is byte-for-byte the old one until a height is named. + * + * @param round the round + * @param parentHeader the parent header + * @return the encoded extra data + * @throws org.hyperledger.besu.consensus.common.bft.PqAnchorNotReadyException when this node + * cannot back the block with a certificate that reaches the staged threshold; callers must + * catch it and simply not propose this round + */ @Override public Bytes createExtraData(final int round, final BlockHeader parentHeader) { + final BftExtraData base; if (forksSchedule .getFork(parentHeader.getNumber() + 1L, parentHeader.getTimestamp()) .getValue() .isValidatorContractMode()) { // vote and validators will come from contract instead of block - final BftExtraData extraData = + base = new BftExtraData( ConsensusHelpers.zeroLeftPad( miningConfiguration.getExtraData(), BftExtraDataCodec.EXTRA_VANITY_LENGTH), @@ -82,9 +109,11 @@ public class QbftBlockCreatorFactory extends BftBlockCreatorFactory (env AERE_BASEFEE_VALIDATE_FORKBLOCK). + * Absent = DISARMED (today's behaviour, byte for byte). A broken value = loud refusal + * AERE-BASEFEE-VALIDATE-CONF-01 at factory construction, i.e. at node startup, never a + * silent disarm. There is no consensus binding on the value: the fleet coordinates on it + * exactly as on the anchor heights. REGISTRY ORDER: first walk the history on the archive + * node (~1.77M unmeasured blocks), only then choose H; activation is the founder's. + */ +package org.hyperledger.besu.consensus.qbft.headervalidationrules; + +import org.hyperledger.besu.ethereum.core.BlockHeader; +import org.hyperledger.besu.ethereum.mainnet.DetachedBlockHeaderValidationRule; +import org.hyperledger.besu.ethereum.mainnet.feemarket.BaseFeeMarket; +import org.hyperledger.besu.ethereum.mainnet.headervalidationrules.BaseFeeMarketBlockHeaderGasPriceValidationRule; + +import java.util.Optional; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Height-gated base-fee validation at QBFT import: the missing rule, armed only above H. */ +public class AereBaseFeeImportRule implements DetachedBlockHeaderValidationRule { + + private static final Logger LOG = LoggerFactory.getLogger(AereBaseFeeImportRule.class); + + /** The disarmed height: no block ever reaches it, upstream behaviour everywhere. */ + public static final long DISARMED = Long.MAX_VALUE; + + /** System property naming the first height at which the rule bites. Absent = disarmed. */ + public static final String PROPERTY_FORK_BLOCK = "aere.basefee.validate.forkBlock"; + + /** Environment fallback for {@link #PROPERTY_FORK_BLOCK}. */ + public static final String ENV_FORK_BLOCK = "AERE_BASEFEE_VALIDATE_FORKBLOCK"; + + private final long armedFromBlock; + private final BaseFeeMarketBlockHeaderGasPriceValidationRule delegate; + + /** + * @param armedFromBlock first height (inclusive) at which the rule bites; {@link #DISARMED} + * for today's behaviour + * @param baseFeeMarket the fee market THIS NODE runs (carries the AERE floor fork), empty on + * a pre-London chain + * @throws IllegalStateException AERE-BASEFEE-VALIDATE-CONF-02 when armed without a fee market: + * an armed rule with nothing to compute against must refuse at startup, not skip silently + */ + public AereBaseFeeImportRule( + final long armedFromBlock, final Optional baseFeeMarket) { + this.armedFromBlock = armedFromBlock; + if (armedFromBlock != DISARMED && baseFeeMarket.isEmpty()) { + throw new IllegalStateException( + "AERE-BASEFEE-VALIDATE-CONF-02: " + PROPERTY_FORK_BLOCK + " is armed at " + + armedFromBlock + " but this chain has no base-fee market to validate against." + + " An armed rule must refuse at startup, never skip silently."); + } + this.delegate = + baseFeeMarket.map(BaseFeeMarketBlockHeaderGasPriceValidationRule::new).orElse(null); + } + + /** + * The arming height the production factory wires in, read from system configuration. + * + * @return the height, or {@link #DISARMED} when the property is not set anywhere + * @throws IllegalStateException AERE-BASEFEE-VALIDATE-CONF-01 on a present but unparseable + * value; the factory runs at node startup, so the refusal lands at config time + */ + public static long armedFromSystemConfig() { + String raw = System.getProperty(PROPERTY_FORK_BLOCK); + if (raw == null) { + raw = System.getenv(ENV_FORK_BLOCK); + } + if (raw == null) { + return DISARMED; + } + try { + final long h = Long.parseLong(raw.trim()); + if (h < 0) { + throw new NumberFormatException("negative"); + } + return h; + } catch (final NumberFormatException e) { + throw new IllegalStateException( + "AERE-BASEFEE-VALIDATE-CONF-01: " + PROPERTY_FORK_BLOCK + + " is set but not a non-negative block height: '" + raw + + "'. A mistyped value must refuse, never silently disarm."); + } + } + + @Override + public boolean validate(final BlockHeader header, final BlockHeader parent) { + // History stays untouched: below H this rule does not exist, first statement, no compute. + if (header.getNumber() < armedFromBlock) { + return true; + } + final boolean ok = delegate.validate(header, parent); + if (!ok) { + LOG.info( + "AERE BASEFEE-VALIDATE: header {} carries a base fee the fee market of this node" + + " (floor included) does not reproduce from its parent - rejected at import", + header.getNumber()); + } + return ok; + } + + @Override + public boolean includeInLightValidation() { + // Same stance as the anchor digest rule: cheap, stateless, and exactly the check a + // header-syncing node can and should make. + return true; + } +} diff --git a/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/FalconSealValidationRule.java b/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/FalconSealValidationRule.java new file mode 100755 index 000000000..1b8bed196 --- /dev/null +++ b/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/FalconSealValidationRule.java @@ -0,0 +1,513 @@ +/* + * Copyright contributors to Besu / AERE Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.qbft.headervalidationrules; + +import static org.hyperledger.besu.consensus.common.bft.BftHelpers.calculateRequiredValidatorQuorum; + +import org.hyperledger.besu.consensus.common.bft.BftBlockHashing; +import org.hyperledger.besu.consensus.common.bft.BftContext; +import org.hyperledger.besu.consensus.common.bft.BftExtraData; +import org.hyperledger.besu.consensus.common.bft.FalconSeal; +import org.hyperledger.besu.consensus.common.bft.FalconSealSupport; +import org.hyperledger.besu.consensus.qbft.QbftExtraDataCodec; +import org.hyperledger.besu.datatypes.Address; +import org.hyperledger.besu.datatypes.Hash; +import org.hyperledger.besu.ethereum.ProtocolContext; +import org.hyperledger.besu.ethereum.core.BlockHeader; +import org.hyperledger.besu.ethereum.mainnet.AttachedBlockHeaderValidationRule; +import org.hyperledger.besu.evm.account.Account; +import org.hyperledger.besu.evm.worldstate.WorldState; + +import java.util.Collection; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.Optional; +import java.util.Set; + +import org.apache.tuweni.units.bigints.UInt256; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Verifies the PARALLEL Falcon-512 post-quantum QUORUM CERTIFICATE embedded in a QBFT block header. + * + *

RETIRED ON CHAIN 2800, AND EVERYTHING BELOW DESCRIBES A RULE THAT NO LONGER APPLIES THERE + * (finding D-235, corrected 2026-08-19). This rule stands down at + * {@code PqAnchorConfig.legacyFalconRuleRetirementBlock()}, which is the anchor block itself + * ({@code everActive() ? anchorBlock : NEVER}). On chain 2800 the anchor block is 13,014,000 and + * {@code aere.falcon.forkBlock} is 14,050,000 - the arming height is ABOVE the retirement height, + * so this rule has never once been in force there, and arming that property changes nothing. What + * actually carries the post-quantum verdict on 2800 is the pair of V2 anchor rules: at every 32nd + * height, a certificate of at least K valid Falcon-512 seals under the block hash. + * + *

The text below is kept because the rule is real code and can be armed on a chain that never + * reached an anchor block; it is not kept as a description of 2800. Until 2026-08-19 the site, the + * whitepaper and seven press releases said a per-block 2f+1 Falcon quorum had been blocking since + * 14,050,000. That claim was withdrawn in public the same day, and the withdrawal is the reason + * this paragraph exists: an auditor reading the code must not find here the claim we retracted. + * + *

A Falcon quorum certificate is the set of Falcon-512 seals gossiped by validators on their QBFT + * commit messages (each a signature over the same commit hash the ECDSA committed seal signs), + * aggregated by the block assembler into the header's parallel Falcon-seal list. + * + *

AERE audit fix (AUD-CONSENSUS-1 / AUD-CONSENSUS-2, 2026-07-18). Both the Falcon quorum + * threshold AND the counted-seal set are now bound to ONE well-defined set: + * + *

+ *   eligibleSigners = currentValidators (getValidatorsAfterBlock(parent))
+ *                     INTERSECT registeredValidatorAddresses (address-bound registry)
+ *   quorum          = ceil(2 * |eligibleSigners| / 3)
+ *   valid           = # distinct eligible validator addresses whose Falcon seal verifies
+ *   accept (blocking) iff eligibleSigners is well-formed AND valid >= quorum
+ * 
+ * + *

This closes the previous decoupling, where the quorum tracked the DYNAMIC validator-set size N + * (so a routine add-validator vote raised the quorum above the FIXED registry key count and halted + * the chain), while seals were counted by REGISTRY membership (so a removed validator's key kept + * counting). Binding both sides to {@code currentValidators INTERSECT registry} makes a + * validator-set change LIVE-SAFE (an added-but-unkeyed validator neither raises the Falcon quorum + * nor contributes to it, so the chain does not halt) and REMOVAL-SAFE (an ex-validator's key is not + * in the current validator set, so it is excluded from both eligibility and the count). + * + *

ECDSA stays decisive. This rule runs AFTER {@code BftCommitSealsValidationRule}, which + * independently requires an ECDSA committed-seal quorum of {@code ceil(2N/3)} over the FULL current + * validator set N. A block is valid iff BOTH quorums pass, so the hybrid chain is a strict subset of + * the ECDSA-only chain and can never be weaker than ECDSA-only. The Falcon eligible-quorum over a + * subset of validators is the decisive post-quantum seal in the regime where ECDSA is forgeable; the + * arming invariant below keeps that subset equal to the full validator set at arm time. + * + *

Behaviour is fork-gated by {@code aere.falcon.forkBlock} / {@code AERE_FALCON_FORKBLOCK}: + * + *

    + *
  • Before the fork block (or when unconfigured, i.e. fork = Long.MAX_VALUE): the rule is + * LOG-ONLY and always returns {@code true}. The decisive seal remains the ECDSA committed-seal + * quorum, so a Falcon fault can never halt or split the chain. This preserves the additive, + * non-blocking baseline exactly. + *
  • At and after the fork block: the rule is BLOCKING. A block is REJECTED unless it carries a + * Falcon quorum certificate of at least {@code ceil(2 * |eligibleSigners| / 3)} distinct valid + * eligible-signer Falcon seals over the correct commit hash. A registry that is not + * address-bound, or an empty eligible set, is a fail-closed REJECT (surfaced at the fork + * boundary, i.e. arm time), NEVER an implicit accept. + *
+ * + *

ARMING INVARIANT (AUD-CONSENSUS-1): blocking should be armed only when the registry COVERS the + * validator set (every current validator has a Falcon key), so that {@code eligibleSigners == + * currentValidators} and the Falcon quorum equals the ECDSA quorum with full fault margin. When + * coverage is incomplete the rule stays LIVE on the intersection (it does not halt) but logs a LOUD + * warning that the margin is reduced and a registry re-anchor is required. This is the "either the + * intersection keeps it live, or it fail-closes at arm time, never as a silent halt" contract. + * + *

STAGE-2 LATE-ANCHOR activation: when the node is configured with a late-anchor manifest + * ({@code aere.falcon.manifest} + {@code aere.falcon.anchor.address}) on a chain that launched + * WITHOUT a genesis manifest, this rule additionally polls the parent block's world state for the + * anchor contract. Once the contract exists and its storage slot 0 carries a hash equal to + * keccak256 of the local manifest, the registry activates ({@link + * FalconSealSupport#activateLateAnchor}); any mismatch is terminal and fail-closed. This is the + * no-re-genesis activation path for an already-live chain. + */ +public class FalconSealValidationRule implements AttachedBlockHeaderValidationRule { + + private static final Logger LOG = LoggerFactory.getLogger(FalconSealValidationRule.class); + + /** + * AERE 2026-08-07: how often the LOG-ONLY summary repeats when NOTHING has changed. + * + *

WHY THIS EXISTS, measured on a live node. The summary was written at INFO on EVERY imported + * block. On chain 2800 at ~523 ms per block that is 2295 lines in twenty minutes, about 165.000 + * a day per node, and every one of them said the same thing: {@code 0 of 0 seals, + * |eligible|=0, no-eligible-signers}. A line that cannot change carries no information, and seven + * validators had just come out of a disk emergency. + * + *

WHAT IS KEPT. Every CHANGE of outcome still logs at INFO immediately, so an operator sees the + * transition into and out of quorum on the block it happens. Unchanged state logs once per + * heartbeat, so the line never disappears entirely from a quiet log. Everything else drops to + * DEBUG, where it is still available on demand. + * + *

10.000 blocks is about 87 minutes at 523 ms. + */ + static final long LOG_HEARTBEAT_BLOCKS = 10_000L; + + /** + * Last outcome logged at INFO, and the height it was logged at. Written from several import + * threads, so both are volatile and are only ever used to decide a LOG LEVEL. Nothing here can + * change what this rule returns: the throttle is applied strictly after the verdict is computed. + */ + private volatile String lastLoggedOutcome = null; + + private volatile long lastLoggedBlock = Long.MIN_VALUE; + + private final QbftExtraDataCodec extraDataCodec = new QbftExtraDataCodec(); + + /** + * AERE ANCORA-V2 (2026-08-01): the height at and after which this rule RETIRES. + * + *

The V2 anchor scheme replaces this rule; it does not promote it. The reasons are measured and + * are properties of this file: {@code includeInLightValidation()} returns FALSE, so it never runs + * on a node that is fast-syncing headers; it returns {@code true} on every path including the + * exception path, so it has never been able to reject anything below the fork height; and its + * late-anchor lookup reads historical world state over a window of roughly 510 blocks, which does + * not exist on a syncing node. Retiring it at exactly the height the two anchor rules take over + * means exactly one regime is in force at any height, with no overlap and no gap. + * + *

{@link Long#MAX_VALUE} means "never retire", which is what the no-argument constructor gives, + * so every existing call site and test keeps today's behaviour unchanged. + */ + private final long retirementBlock; + + /** Default constructor: the rule never retires, i.e. exactly today's behaviour. */ + public FalconSealValidationRule() { + this(Long.MAX_VALUE); + } + + /** + * Decides whether the LOG-ONLY summary goes out at INFO, and records it if so. + * + *

Extracted so the throttle can be PROVEN without a log appender: a test that counts real log + * lines proves the plumbing, but a test on this method proves the rule, and the rule is what can + * be wrong. The end-to-end count is measured separately, on a live node. + * + *

Two reasons to log at INFO, and no others: + * + *

    + *
  • the outcome CHANGED since the last INFO, so an operator sees every transition on the block + * it happens + *
  • {@link #LOG_HEARTBEAT_BLOCKS} have passed, so the line never vanishes from a quiet log + *
+ * + *

This can only pick a log level. It is called strictly after the verdict is computed and its + * result is never read by anything that decides validity. + * + * @param rezumat a stable key for the outcome; equal keys mean nothing an operator cares about has + * changed + * @param blockNumber the height being validated + * @return true to log at INFO, false to drop to DEBUG + */ + boolean shouldLogAtInfo(final String rezumat, final long blockNumber) { + final boolean hasChanged = !rezumat.equals(lastLoggedOutcome); + // Long.MIN_VALUE as "never logged" cannot be subtracted from without overflowing, and an + // overflow here would silently invert the comparison: the first block would take the DEBUG + // branch and the very first line, the one that tells an operator the rule is alive at all, + // would never appear. + final boolean bataieDeInima = + lastLoggedBlock == Long.MIN_VALUE || blockNumber - lastLoggedBlock >= LOG_HEARTBEAT_BLOCKS; + if (hasChanged || bataieDeInima) { + lastLoggedOutcome = rezumat; + lastLoggedBlock = blockNumber; + return true; + } + return false; + } + + /** + * Constructor with an explicit retirement height. + * + * @param retirementBlock the height at and after which this rule stands down in favour of the V2 + * anchor rules; {@link Long#MAX_VALUE} to never retire + */ + public FalconSealValidationRule(final long retirementBlock) { + this.retirementBlock = retirementBlock; + } + + @Override + public boolean validate( + final BlockHeader header, final BlockHeader parent, final ProtocolContext protocolContext) { + // AERE ANCORA-V2: RETIREMENT GATE FIRST, before any singleton touch, any world-state lookup and + // any decode. From H this rule contributes nothing, and the two anchor rules carry the whole + // post-quantum verdict. Below H this branch is not taken and behaviour is bit-for-bit as before. + if (header.getNumber() >= retirementBlock) { + return true; + } + + final FalconSealSupport pqc = FalconSealSupport.instance(); + + // STAGE-2: opportunistic late-anchor activation from the parent block's world state. Never + // throws; pre-activation this is a cheap account lookup per imported block until the anchor + // contract appears on-chain. + if (pqc.lateAnchorPending()) { + tryActivateLateAnchor(pqc, parent, protocolContext); + } + + // AERE FIX-OPRIRE-CONSENS (c): a TERMINALLY FAILED late anchor (an on-chain anchor was observed + // and did NOT match the local manifest) is a tamper signal, not a "not yet" signal. The old code + // could not tell the two apart: both left genesisAnchored()==false and lateAnchored()==false, so + // BLOCKING was simply never armed and the whole PQC layer degraded to LOG-ONLY - it failed OPEN + // on exactly the input an attacker controls. FAILED now fails CLOSED at and after the fork + // block, while PENDING keeps the deliberate log-only behaviour that lets a legitimate anchor + // transaction still land (see AUD-CONSENSUS-4 below). + final long forkBlock = pqc.forkBlock(); + if (header.getNumber() >= forkBlock && pqc.lateAnchorFailed()) { + LOG.error( + "AERE PQC (BLOCKING, FAIL-CLOSED): block {} REJECTED - the late-anchor manifest is " + + "TERMINALLY FAILED. keccak256(local manifest) did not match the hash in the " + + "on-chain anchor contract {} slot 0, so the Falcon registry is permanently empty. " + + "This is a tampered or mismatched anchor, NOT an anchor that has yet to land, and " + + "it must not silently degrade PQC enforcement to log-only. Re-anchor with the " + + "correct manifest, or restart the node with the manifest that matches the chain.", + header.getNumber(), + pqc.anchorAddress()); + return false; + } + // AUD-CONSENSUS-4: entering BLOCKING mode requires BOTH the fork height AND an ACTIVE anchored + // registry (genesis-anchored, or a late anchor already activated by tryActivateLateAnchor + // above). If forkBlock is armed at or before the late-anchor observation height, the registry is + // not yet active when the fork block is validated; blocking there rejects every block (empty + // registry => no eligible seal) and permanently HALTS the chain before the anchor-deploy + // transaction can settle. Staying LOG-ONLY until the registry is anchored lets the chain reach + // the anchor-deploy height, activate just-in-time at validate(D+1), and only THEN enforce the + // Falcon quorum. This never loses PQC enforcement: with no active registry there are no keys to + // verify against, so blocking would provide zero safety, only a halt. + final boolean forkReached = header.getNumber() >= forkBlock; + final boolean registryActive = pqc.genesisAnchored() || pqc.lateAnchored(); + final boolean blocking = forkReached && registryActive; + // AERE D-079: leave a MARK, not only a line. The log-only answer below is the right answer for a + // header rule, and it is also how this condition used to vanish: the node was configured to + // enforce a post-quantum quorum, enforced nothing, and said so once per block into a file. The + // counter is readable from a test and from a JMX/diagnostic path; the WARN is emitted only on + // the first occurrence, because one line per block at a sub-second block period is itself a + // hazard on this fleet. + if (forkReached + && !registryActive + && pqc.noteBlockingArmedWithoutActiveRegistry(header.getNumber())) { + LOG.warn( + "AERE PQC: block {} is at/after the Falcon fork block ({}) but the anchored registry is " + + "NOT yet active (genesisAnchored={}, lateAnchored={}, lateAnchorPending={}). Staying " + + "LOG-ONLY so the chain can reach the anchor height and activate; the Falcon quorum " + + "becomes BLOCKING only once the registry is anchored. If this persists, the " + + "late-anchor contract has not settled - deploy/observe it before relying on PQC " + + "enforcement.", + header.getNumber(), + forkBlock, + pqc.genesisAnchored(), + pqc.lateAnchored(), + pqc.lateAnchorPending()); + } + + try { + final BftExtraData extraData = extraDataCodec.decodeRaw(header.getExtraData()); + final Collection falconSeals = extraData.getFalconSeals(); + + final BftContext bftContext = protocolContext.getConsensusContext(BftContext.class); + final Collection

validators = + bftContext.getValidatorProvider().getValidatorsAfterBlock(parent); + + // AERE FIX-OPRIRE-CONSENS (b): feed the authoritative validator set to the seal-ATTACHMENT + // gate. This is the only place in the QBFT stack that already holds a validator set bound to a + // height, and it runs on every imported block, i.e. always before the next round's commit. + // The gate refuses to attach until the anchored registry covers ALL of these addresses. + pqc.observeValidators(header.getNumber(), validators); + + // AERE audit fix (AUD-CONSENSUS-1 / -2): the eligible-signer set is the intersection of the + // CURRENT validator set with the address-bound signer registry. BOTH the quorum and the + // counted-seal set are derived from this ONE set, so neither can drift from the other. + final Set
registered = pqc.registeredValidatorAddresses(); + final Set
eligible = new LinkedHashSet<>(); + for (final Address v : validators) { + if (registered.contains(v)) { + eligible.add(v); + } + } + final int quorum = calculateRequiredValidatorQuorum(eligible.size()); + + final Hash commitHash = + new BftBlockHashing(extraDataCodec).calculateDataHashForCommittedSeal(header); + + // Count DISTINCT ELIGIBLE validator addresses whose Falcon seal verifies over the commit hash. + // A seal counts only if its registry-bound address is an eligible signer (i.e. a current + // validator with a registered key); seals from registered-but-removed validators, or from + // unregistered indices, are excluded. + // D2 (2026-08-06): resolve the key set AT THE HEIGHT OF THE HEADER CARRYING THE SEAL, not at + // this node's head. These seals are over THIS header's committed-seal hash, so the height is + // this header's own - unlike R2, whose certificate commits to the PARENT. The adversarial + // review measured this rule asking a height-less registry; below the arming height the + // resolver still answers from the head registry, so the 11.8 million blocks already on chain + // 2800 are checked exactly as before, but the rule can no longer be the reason a rotation + // makes history unverifiable. + final Set
counted = new HashSet<>(); + for (final FalconSeal seal : falconSeals) { + // D2 (b-v2): the HISTORY door. R1's seals are over THIS header's committed-seal hash, + // so the height is the header's own; the header still came from outside. + final Address signer = + pqc.addressForIndexAtHistoric(header.getNumber(), seal.getValidatorIndex()); + if (signer == null || !eligible.contains(signer) || counted.contains(signer)) { + continue; + } + if (pqc.verifyAtHistoric( + header.getNumber(), + seal.getValidatorIndex(), + commitHash.getBytes(), + seal.getSignature())) { + counted.add(signer); + } else if (blocking) { + LOG.warn( + "AERE PQC (BLOCKING): block {} carried a Falcon seal from validator index {} " + + "(address {}) that did NOT verify. It does not count toward the quorum.", + header.getNumber(), + seal.getValidatorIndex(), + signer); + } + } + final int valid = counted.size(); + + if (blocking) { + // Fail-closed (arm-time surfaced): a registry that is not address-bound, or a validator set + // that shares no keyed signer with the registry, cannot carry a genuine eligible Falcon + // quorum. Reject rather than let ceil(2*0/3)=0 be trivially met (that would be fail-OPEN). + if (!pqc.addressBound() || eligible.isEmpty()) { + LOG.error( + "AERE PQC (BLOCKING): block {} REJECTED (fail-closed): no eligible Falcon signer set " + + "(addressBound={}, |eligible|={}, N={}, registrySize={}). Blocking requires an " + + "ADDRESS-BOUND registry that covers current validators; re-anchor before arming.", + header.getNumber(), + pqc.addressBound(), + eligible.size(), + validators.size(), + pqc.registrySize()); + return false; + } + + // ARMING INVARIANT (loud, non-halting): if the registry does not cover the full validator + // set, the chain stays LIVE on the intersection but the two-fault margin is reduced. This is + // the signal that a validator was added without re-anchoring; it is never a silent halt. + if (!pqc.registryCoversValidators(validators)) { + LOG.warn( + "AERE PQC (BLOCKING): block {} - registry does NOT cover the validator set " + + "(|eligible|={} < N={}). Running on the eligible intersection (LIVE, quorum={}), " + + "but two-fault liveness margin is reduced. A validator was added without an " + + "atomic registry re-anchor: RE-ANCHOR the Falcon manifest for the full validator " + + "set (see PQ-CONSENSUS-LIVE-READINESS validator-expansion procedure).", + header.getNumber(), + eligible.size(), + validators.size(), + quorum); + } + + if (valid >= quorum) { + LOG.info( + "AERE PQC (BLOCKING): block {} Falcon quorum certificate OK: {} distinct valid " + + "eligible seal(s) >= quorum {} of |eligible|={} (N={}). Post-quantum seal is " + + "consensus-enforced alongside ECDSA.", + header.getNumber(), + valid, + quorum, + eligible.size(), + validators.size()); + return true; + } + LOG.warn( + "AERE PQC (BLOCKING): block {} REJECTED: only {} distinct valid eligible Falcon seal(s) " + + "< quorum {} of |eligible|={} (N={}). A post-fork block MUST carry a Falcon quorum " + + "certificate over the eligible-signer set.", + header.getNumber(), + valid, + quorum, + eligible.size(), + validators.size()); + return false; + } + + // Pre-fork: log-only, never blocks. + final String state = + eligible.isEmpty() + ? "no-eligible-signers" + : (valid >= quorum ? "PQC-QUORUM-MET" : "PQC-quorum-not-yet"); + // The verdict is already decided above. Everything below only picks a LOG LEVEL. + final String rezumat = + state + "|" + valid + "|" + falconSeals.size() + "|" + eligible.size() + "|" + validators.size(); + final boolean laInfo = shouldLogAtInfo(rezumat, header.getNumber()); + final String message = + "AERE PQC (LOG-ONLY): block {} -> {} of {} Falcon seal(s) verified over |eligible|={} " + + "(N={}); 2/3 eligible quorum would be {} [{}]. This check never blocks pre-fork; " + + "ECDSA committed seals remain decisive."; + if (laInfo) { + lastLoggedOutcome = rezumat; + lastLoggedBlock = header.getNumber(); + LOG.info( + message, + header.getNumber(), + valid, + falconSeals.size(), + eligible.size(), + validators.size(), + quorum, + state); + } else { + LOG.debug( + message, + header.getNumber(), + valid, + falconSeals.size(), + eligible.size(), + validators.size(), + quorum, + state); + } + return true; + } catch (final Exception e) { + if (blocking) { + LOG.warn( + "AERE PQC (BLOCKING): block {} REJECTED due to a Falcon quorum check error: {}", + header.getNumber(), + e.toString()); + return false; + } + // Pre-fork: NEVER block on a Falcon fault. Log and accept; ECDSA remains decisive. + LOG.debug("AERE PQC (LOG-ONLY): skipped Falcon check for a block: {}", e.toString()); + return true; + } + } + + /** + * STAGE-2: look up the anchor contract in the parent block's world state; if its storage slot 0 + * is non-zero, attempt registry activation against that hash. Defensive: never throws, never + * blocks; until the contract exists this is a single account lookup per imported block. + */ + private void tryActivateLateAnchor( + final FalconSealSupport pqc, final BlockHeader parent, final ProtocolContext protocolContext) { + try { + final Optional ws = + protocolContext.getWorldStateArchive().get(parent.getStateRoot(), parent.getHash()); + if (ws.isEmpty()) { + return; + } + final Account account = ws.get().get(Address.fromHexString(pqc.anchorAddress())); + if (account == null) { + return; + } + final UInt256 slot0 = account.getStorageValue(UInt256.ZERO); + if (slot0 == null || slot0.isZero()) { + return; + } + pqc.activateLateAnchor(slot0.toBytes().toUnprefixedHexString()); + } catch (final Exception e) { + LOG.debug( + "AERE PQC: late-anchor lookup skipped at parent {}: {}", + parent.getNumber(), + e.toString()); + } + } + + @Override + public boolean includeInLightValidation() { + return false; + } + + @Override + public String toString() { + return "FalconSealValidation(fork-gated blocking, eligible-signer intersection, late-anchor " + + "capable, retiresAt=" + + (retirementBlock == Long.MAX_VALUE ? "NEVER" : retirementBlock) + + ")"; + } +} diff --git a/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorDigestAttachedRule.java b/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorDigestAttachedRule.java new file mode 100755 index 000000000..896f0f477 --- /dev/null +++ b/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorDigestAttachedRule.java @@ -0,0 +1,115 @@ +/* + * Copyright contributors to Besu / AERE Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.qbft.headervalidationrules; + +import org.hyperledger.besu.consensus.common.bft.PqAnchorConfig; +import org.hyperledger.besu.ethereum.ProtocolContext; +import org.hyperledger.besu.ethereum.core.BlockHeader; +import org.hyperledger.besu.ethereum.mainnet.AttachedBlockHeaderValidationRule; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * R1A: the anchor digest rule again, on the ATTACHED side of the validator. + * + *

Why a second copy of a rule that already exists. {@link PqAnchorDigestRule} is DETACHED. + * Besu decides which rules run from the {@code HeaderValidationMode}, and the two facts that matter + * were measured, not assumed: + * + *

    + *
  • {@code HeaderValidationMode.SKIP_DETACHED} keeps exactly the rules for which {@code + * isDetachedSupported()} is false. That is the mode {@code FullImportBlockStep} passes to + * {@code BlockImporter.importBlock} for every block acquired by full sync. So a detached rule + * does NOT run at import. + *
  • {@code DETACHED_ONLY}, the mode under which a detached rule does run during download, is + * applied by {@code DownloadHeaderSequenceTask} and therefore only on ranges that {@code + * SyncTargetRange.hasEnd()} reports as closed. The open-ended range at the tip of a recovery + * took the other branch of {@code DownloadHeadersStep} and was validated by nothing. + *
+ * + *

Together those two facts left a hole of the last {@code downloader-header-request-size} minus + * one blocks, 199 by default, of every catch-up: on those the certificate binding was checked by no + * rule at all. A node that had been down for under about 103 seconds recovered its entire gap + * through that window. This rule closes it from the other side: since it is ATTACHED it survives + * {@code SKIP_DETACHED}, so the binding is now enforced on every block that enters the database, by + * whichever path it arrived. + * + *

It is the same verdict, not a second opinion. The check is delegated to the one {@link + * PqAnchorDigestRule} instance rather than reimplemented, so there is exactly one definition of the + * anchor digest and no way for the two paths to drift apart. The delegate ignores its {@code parent} + * argument entirely - the digest is computed from {@code header.getParentHash()}, {@code + * header.getNumber() - 1}, the configured chain id and element 6 of the header's own extraData - so + * running it from an attached position is not merely permissible, it is the same pure function of + * the same header. + * + *

Deliberately NOT in light validation. {@link PqAnchorDigestRule} already covers every + * light mode. Returning false here keeps this rule out of proposal-time validation and confines it + * to {@code FULL} and {@code SKIP_DETACHED}, which is the import path. That is the smallest change + * that closes the hole, and it means the cost added to a path that was already paying for the check + * is nil. + * + *

Inactive below H. The delegate's height gate is its first statement, before any decode, + * so below the activation height this rule is one virtual call and a comparison. A binary carrying + * it behaves exactly as today on the whole existing chain. + */ +public class PqAnchorDigestAttachedRule implements AttachedBlockHeaderValidationRule { + + private static final Logger LOG = LoggerFactory.getLogger(PqAnchorDigestAttachedRule.class); + + private final PqAnchorConfig config; + private final PqAnchorDigestRule delegate; + + /** + * Instantiates the attached half of the anchor digest rule. + * + * @param config the height-indexed activation configuration + */ + public PqAnchorDigestAttachedRule(final PqAnchorConfig config) { + this.config = config; + this.delegate = new PqAnchorDigestRule(config); + } + + @Override + public boolean validate( + final BlockHeader header, final BlockHeader parent, final ProtocolContext protocolContext) { + final boolean ok = delegate.validate(header, parent); + if (!ok) { + // The delegate has already logged WHY. This line exists so that the path can be told apart in + // a log: a rejection carrying R1A was caught while the block was being imported, which is the + // window that used to be open, whereas R1 alone means it was caught while headers were being + // downloaded. + LOG.warn( + "AERE PQ ANCHOR (R1A): block {} REJECTED on the ATTACHED path. This is the import-time " + + "copy of the digest binding; it is what catches a stripped or swapped certificate " + + "on the open-ended range at the tip of a sync, which detached validation never " + + "reaches.", + header.getNumber()); + } + return ok; + } + + @Override + public boolean includeInLightValidation() { + return false; + } + + @Override + public String toString() { + return "PqAnchorDigestAttached(attached, not-light, " + + (config.everActive() ? "H=" + config.anchorBlock() : "INACTIVE") + + ")"; + } +} diff --git a/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorDigestRule.java b/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorDigestRule.java new file mode 100755 index 000000000..3b80c4302 --- /dev/null +++ b/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorDigestRule.java @@ -0,0 +1,310 @@ +/* + * Copyright contributors to Besu / AERE Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.qbft.headervalidationrules; + +import org.hyperledger.besu.consensus.common.bft.BftExtraData; +import org.hyperledger.besu.consensus.common.bft.BftExtraDataCodec; +import org.hyperledger.besu.consensus.common.bft.FalconSeal; +import org.hyperledger.besu.consensus.common.bft.PqAnchor; +import org.hyperledger.besu.consensus.common.bft.PqAnchorConfig; +import org.hyperledger.besu.consensus.common.bft.PqAnchorLapse; +import org.hyperledger.besu.consensus.common.bft.PqAnchorV2; +import org.hyperledger.besu.consensus.common.bft.SchemeSeal; +import org.hyperledger.besu.consensus.qbft.QbftExtraDataCodec; +import org.hyperledger.besu.ethereum.core.BlockHeader; +import org.hyperledger.besu.ethereum.mainnet.DetachedBlockHeaderValidationRule; + +import java.util.List; + +import org.apache.tuweni.bytes.Bytes; +import org.apache.tuweni.bytes.Bytes32; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * R1: the anchor DIGEST rule. DETACHED, and deliberately part of LIGHT validation. + * + *

This rule is the whole point of the V2 scheme. It is what makes the sentence "the post-quantum + * signature protects the block" true rather than decorative: the block's own vanityData, which IS in + * the block-hash pre-image, carries the digest of the certificate the block carries for its parent. + * Strip the certificate, swap it for a different but equally valid one, or reorder it, and the digest + * no longer matches and the header is refused. + * + *

Pure function of the header. It reads {@code header.getParentHash()}, {@code + * header.getNumber() - 1}, the configured chain id, and element 6 of the header's OWN extraData. No + * state, no registry, no loaded parent, no Falcon. One keccak. That is why it is cheap enough to be + * declared part of light validation, and it is so declared. + * + *

WHERE IT ACTUALLY RUNS. MEASURED 2026-08-02, and this paragraph CORRECTS an earlier claim in + * this same file. The earlier text read "a node that fast-syncs headers still catches a stripped + * or swapped certificate". That sentence is false in every configuration this binary can reach, and + * it was re-measured against the compiled jars of this tree: + * + *

    + *
  • {@code SyncMode} in this Besu has exactly TWO values, {@code FULL} and {@code SNAP}. There is + * no fast sync here to speak of. [MEASURED: enumerated from the compiled {@code SyncMode}.] + *
  • With the anchor armed, {@code PqAnchorSyncModeGuard} REFUSES TO START any node whose mode is + * not {@code FULL}. {@code SNAP}, null, blank and unrecognised all abort. Refusing to start is + * not catching anything. [MEASURED: 7 of 7 assertions against the compiled guard, and on a + * real node in {@code dovezi-sincronizare-2026-08-02/evidence/SNAP-AFTER-armed.txt}.] + *
  • With the anchor NOT armed, or disarmed by the emergency option, every mode starts again and + * this rule is inert by its own height gate. [MEASURED: both negative controls of the same + * probe, which is what shows the probe is measuring the anchor and not the mode string.] + *
+ * + *

So do not read the light-validation flag on this rule as a statement about a syncing node. What + * makes this rule reach the history a node ACCEPTS is two other things, in two other files: {@code + * DownloadHeadersStep} now applying the same validation policy to OPEN-ENDED ranges that it already + * applied to closed ones, and {@link PqAnchorDigestAttachedRule}, which carries the identical verdict + * on the ATTACHED side so that it survives {@code SKIP_DETACHED} at import. Before those two existed + * an honest node with its data directory deleted imported stripped-certificate headers in silence and + * reached head 350 with ZERO occurrences of this rule. [MEASURED: + * {@code dovezi-sincronizare-2026-08-02/evidence/OBS-BEFORE.txt}; and the negative control + * {@code OBS-CONTROL-STUBBED.txt} reproduces exactly that silence once the two guards are stubbed.] + * + *

includeInLightValidation, measured. Both {@code DetachedBlockHeaderValidationRule} and + * {@code AttachedBlockHeaderValidationRule} declare {@code includeInLightValidation()} with a DEFAULT + * of {@code true}. {@code BlockHeaderValidator.Builder.addRule(DetachedBlockHeaderValidationRule)} + * snapshots that value at build time into the wrapping {@code Rule}, and the modes {@code + * LIGHT_DETACHED_ONLY}, {@code LIGHT} and {@code DETACHED_ONLY} all admit a detached rule whose flag + * is true. This rule overrides the method anyway, explicitly returning true, so the property is + * asserted by this file rather than inherited silently from an upstream default that a Besu rebase + * could change under us. + * + *

Inactive below H. The height gate is the FIRST statement, before any decode. Below H this + * rule does not even parse the header, so a binary carrying it behaves exactly as today on the whole + * existing chain. That is the condition under which such a binary can be warmed on a live node. + * + *

Fail-closed above H. At or above H, anything that prevents the digest from being computed + * and matched is a REJECT: a non-canonical or undecodable extraData, a vanity field that is not 32 + * bytes, a certificate whose indices are not strictly increasing, or a mismatching digest. Never an + * implicit accept, and specifically never "true on exception", which is the defect that made the + * legacy {@code FalconSealValidationRule} unable to catch anything. + * + *

The residual exposure this does NOT close, stated plainly: the certificate is outside the + * block-hash pre-image, so a hostile peer can serve a header with the certificate mangled and that + * header becomes unimportable although its block hash is unchanged. That is denial of service at + * header service, not forgery, and it is the same exposure the ECDSA committed seals already carry + * today. + */ +public class PqAnchorDigestRule implements DetachedBlockHeaderValidationRule { + + private static final Logger LOG = LoggerFactory.getLogger(PqAnchorDigestRule.class); + + private final PqAnchorConfig config; + private final QbftExtraDataCodec extraDataCodec = new QbftExtraDataCodec(); + + /** + * Instantiates the anchor digest rule. + * + * @param config the height-indexed activation configuration + */ + public PqAnchorDigestRule(final PqAnchorConfig config) { + this.config = config; + } + + @Override + public boolean validate(final BlockHeader header, final BlockHeader parent) { + final long number = header.getNumber(); + + // HEIGHT GATE FIRST. Below H nothing at all happens: no decode, no allocation, no log. This is + // the property that lets a binary built from this code run on a live node before activation and + // behave bit-for-bit as today. From 2026-08-07 the gate also covers the interval: at a height + // that carries no certificate there is nothing to bind, so there is nothing to judge. + // + // THE SAME METHOD AS R2, and not two questions that merely resemble each other. See the note in + // PqAnchorSealsRule. + if (!config.anchorAppliesAt(number)) { + return true; + } + + // SECOND GATE: the named historical windows in which these rules were not in force. Inside one + // of them vanityData carries the client's version string rather than a digest, so there is no + // digest to match and no recomputation can produce one; refusing would stop this node forever + // at a height the whole network holds. See PqAnchorLapse for what happened, how the bounds were + // measured, and why an exception written anywhere other than validation moves the error instead + // of ending it. + if (PqAnchorLapse.isDisarmed(number)) { + return acceptAsWrittenInsideLapse(header, number); + } + + try { + final BftExtraData extraData = extraDataCodec.decodeRaw(header.getExtraData()); + + final Bytes vanity = extraData.getVanityData(); + if (vanity.size() != BftExtraDataCodec.EXTRA_VANITY_LENGTH) { + LOG.warn( + "AERE PQ ANCHOR (R1): block {} REJECTED - vanityData is {} bytes, expected {}. From H " + + "the whole vanity field is the anchor digest D.", + number, + vanity.size(), + BftExtraDataCodec.EXTRA_VANITY_LENGTH); + return false; + } + + // AERE ANCHOR V2 (2026-09-03): from the v2 height the ONLY accepted form is the scheme-tagged + // certificate under the v2 digest; below it a v2 certificate is refused. Both directions + // are checked so the switch is a clean fork and never a header two nodes read differently. + if (config.anchorV2AppliesAt(number)) { + return judgeV2(number, header, extraData, vanity); + } + if (!extraData.getHybridSeals().isEmpty()) { + LOG.warn( + "AERE PQ ANCHOR (R1): block {} REJECTED - carries a v2 (scheme-tagged) certificate below" + + " the v2 activation height {}.", + number, + config.anchorV2Block()); + return false; + } + final List certificate = List.copyOf(extraData.getFalconSeals()); + // Height aware since 2026-08-11. Inside the one measured historical window the ordering + // requirement relaxes from SORTED to DISTINCT, because 36 canonical headers were written + // unsorted during a production interruption and a node syncing from genesis would otherwise + // stop at the first of them and never pass it. Distinctness is not relaxed at any height, so a + // repeated index stays unrepresentable everywhere. See PqAnchor.hasAcceptableIndices. + if (!PqAnchor.hasAcceptableIndices(certificate, number)) { + // Checked HERE, not only in the seals rule, so reordering and duplicate indices are caught + // in LIGHT validation too, and so that "the order given" and "sorted" are the same list for + // every header this rule accepts. + LOG.warn( + "AERE PQ ANCHOR (R1): block {} REJECTED - the carried certificate's validator indices " + + "are not strictly increasing ({} seal(s)). Exactly one order is accepted for a " + + "given set, which is also what makes a repeated index unrepresentable.", + number, + certificate.size()); + return false; + } + + final Bytes32 expected = + PqAnchor.anchorDigest( + config.chainId(), number - 1L, header.getParentHash().getBytes(), certificate); + + if (!expected.equals(Bytes32.wrap(vanity))) { + LOG.warn( + "AERE PQ ANCHOR (R1): block {} REJECTED - anchor digest mismatch. vanityData carries " + + "{} but the certificate actually carried in this header ({} seal(s), over parent " + + "{} at height {}) digests to {}. The certificate was stripped, replaced or " + + "reordered after the proposer wrote it.", + number, + vanity, + certificate.size(), + header.getParentHash(), + number - 1L, + expected); + return false; + } + + return true; + } catch (final Exception e) { + // FAIL CLOSED. The legacy Falcon rule returned true on every path including this one, which is + // why it could never catch anything. A rule that cannot compute its verdict has not proved the + // header good. + LOG.warn( + "AERE PQ ANCHOR (R1): block {} REJECTED - the anchor digest could not be computed: {}", + number, + e.toString()); + return false; + } + } + + /** AERE ANCHOR V2: vanityData must be the v2 digest over the carried scheme-tagged certificate. */ + private boolean judgeV2( + final long number, final BlockHeader header, final BftExtraData extraData, final Bytes vanity) { + final List seals = extraData.getHybridSeals(); + if (seals.isEmpty()) { + LOG.warn( + "AERE PQ ANCHOR (R1): block {} REJECTED - at or above the v2 activation height {} the header" + + " carries no scheme-tagged certificate ({} v1 Falcon seal(s) instead).", + number, + config.anchorV2Block(), + extraData.getFalconSeals().size()); + return false; + } + final Bytes32 expected = + PqAnchorV2.anchorDigestV2( + config.chainId(), number - 1L, header.getParentHash().getBytes(), seals); + if (!expected.equals(Bytes32.wrap(vanity))) { + LOG.warn( + "AERE PQ ANCHOR (R1): block {} REJECTED - v2 anchor digest mismatch. vanityData carries {}" + + " but the {}-seal scheme-tagged certificate actually carried digests to {}. The" + + " certificate was stripped, replaced or reordered after the proposer wrote it.", + number, + vanity, + seals.size(), + expected); + return false; + } + return true; + } + + /** + * Accept a header from a named unenforced window as it was written, after the one check that is + * not relaxed at any height. + * + *

Distinctness is kept and the difference is the whole reason this is not a bare {@code return + * true}: sortedness fixes ONE accepted order for a given set, while distinctness is what makes + * "repeat one seal to inflate the count" unrepresentable in the grammar of the format. Every + * header of the measured window has distinct, non-negative indices, so this admits exactly the + * history that exists and nothing weaker. + * + *

Decoding must still succeed. A header this rule cannot parse has not been shown to be one of + * the headers the window is about, so it is refused here as everywhere else in this file. + * + * @param header the header being judged + * @param number its height + * @return true iff the carried certificate decodes and its indices are distinct and non-negative + */ + private boolean acceptAsWrittenInsideLapse(final BlockHeader header, final long number) { + try { + final BftExtraData extraData = extraDataCodec.decodeRaw(header.getExtraData()); + final List certificate = List.copyOf(extraData.getFalconSeals()); + if (!PqAnchor.hasDistinctNonNegativeIndices(certificate)) { + LOG.warn( + "AERE PQ ANCHOR (R1): block {} REJECTED - it falls inside the historical range {} in " + + "which the anchor rules were not in force, but its certificate repeats a " + + "validator index or carries a negative one. That is not a shape any header of " + + "that range has, and it is the one property the exception does not relax.", + number, + PqAnchorLapse.windowCovering(number).orElse(null)); + return false; + } + LOG.debug( + "AERE PQ ANCHOR (R1): block {} accepted as written; it falls inside the historical range " + + "{} in which the anchor rules were not in force, so it carries no digest to bind.", + number, + PqAnchorLapse.windowCovering(number).orElse(null)); + return true; + } catch (final Exception e) { + LOG.warn( + "AERE PQ ANCHOR (R1): block {} REJECTED - it falls inside a historical unenforced range " + + "but its extraData could not be decoded: {}", + number, + e.toString()); + return false; + } + } + + @Override + public boolean includeInLightValidation() { + return true; + } + + @Override + public String toString() { + return "PqAnchorDigest(detached, light, " + + (config.everActive() ? "H=" + config.anchorBlock() : "INACTIVE") + + ")"; + } +} diff --git a/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorSealsRule.java b/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorSealsRule.java new file mode 100755 index 000000000..78423af47 --- /dev/null +++ b/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorSealsRule.java @@ -0,0 +1,579 @@ +/* + * Copyright contributors to Besu / AERE Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.qbft.headervalidationrules; + +import org.hyperledger.besu.consensus.common.bft.BftContext; +import org.hyperledger.besu.consensus.common.bft.BftExtraData; +import org.hyperledger.besu.consensus.common.bft.FalconSeal; +import org.hyperledger.besu.consensus.common.bft.FalconSealSupport; +import org.hyperledger.besu.consensus.common.bft.HybridSealSupport; +import org.hyperledger.besu.consensus.common.bft.HybridSignerRegistry; +import org.hyperledger.besu.consensus.common.bft.PqAnchor; +import org.hyperledger.besu.consensus.common.bft.PqAnchorConfig; +import org.hyperledger.besu.consensus.common.bft.PqAnchorLapse; +import org.hyperledger.besu.consensus.common.bft.PqAnchorV2; +import org.hyperledger.besu.consensus.common.bft.PqSignerRegistry; +import org.hyperledger.besu.consensus.common.bft.SchemeSeal; +import org.hyperledger.besu.consensus.common.bft.SealScheme; +import org.hyperledger.besu.consensus.common.bft.SealSchemes; +import org.hyperledger.besu.consensus.qbft.QbftExtraDataCodec; +import org.hyperledger.besu.datatypes.Address; +import org.hyperledger.besu.ethereum.ProtocolContext; +import org.hyperledger.besu.ethereum.core.BlockHeader; +import org.hyperledger.besu.ethereum.mainnet.AttachedBlockHeaderValidationRule; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Optional; +import java.util.List; +import java.util.OptionalInt; +import java.util.Set; + +import org.apache.tuweni.bytes.Bytes32; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * R2: the anchor SEALS rule. ATTACHED, full validation only. + * + *

Where R1 proves that the header commits to the certificate it carries, R2 proves the + * certificate is worth something. It checks exactly four things, and every one of them is a function + * of the header, the parent header, and configuration. NONE of them is a comparison against what + * this node happened to hear. + * + *

    + *
  1. k = |C| is at least K(height), the staged threshold; + *
  2. the validator indices are STRICTLY increasing, so there is one accepted order and a repeated + * index is unrepresentable; + *
  3. every index maps, through the registry, to an address in the PARENT's validator set; + *
  4. every signature verifies as Falcon-512 over M(parent). + *
+ * + *

Why the "what I heard" comparison is absent, and why that is the entire point. Measured + * on 200 consecutive live headers read simultaneously from two of our own nodes, with the block hash + * identical 200/200: the seal ORDER differed on 68 and the seal SET differed on 11. Any rule that + * required the carried certificate to match the local view would therefore reject roughly one header + * in eighteen with no attacker present, and at quorum 5 of 7 that is repeated stalling. Here a node + * that heard 7 accepts the proposer's certificate of 5, because Falcon verification is public: the + * validator does not need to have heard those commit messages to check them. The same construction + * also removes the third divergence axis for free, the one where a node signs the same block twice + * (once in {@code updateStateWithProposedBlock}, once in {@code peerIsPrepared}): both signatures are + * valid, the proposer writes one, everyone verifies it, and whether Falcon signing is deterministic + * stops mattering. That question remains NOT MEASURED and, under this scheme, no longer needs to be. + * + *

Not in light validation. This rule needs the consensus context and the registry, so + * {@code includeInLightValidation()} returns false. The split is deliberate: the part that binds the + * certificate to the block hash is one keccak and runs everywhere, the part that costs k Falcon + * verifications runs only where state exists. Measured Falcon verification is 0.068 ms median and + * 0.249 ms p95, so five verifications are under 1.3 ms against a measured 517 ms block interval. + * + *

Inactive below H, checked as the first statement, before any decode. + * + *

Fail-closed above H, on every path including exceptions and including a missing or + * mismatched parent. + * + *

LIVENESS COST, stated because it is real: the threshold makes the certificate a liveness + * condition. A proposer that cannot gather K seals for the parent cannot propose at all. At N=7 the + * quorum is 5 and N-f is 5, i.e. ZERO margin, which is why the validator set must reach N>=9 + * before this is armed with a quorum threshold. How often that stalls in practice is NOT MEASURED, + * because nothing has been run. + */ +public class PqAnchorSealsRule implements AttachedBlockHeaderValidationRule { + + private static final Logger LOG = LoggerFactory.getLogger(PqAnchorSealsRule.class); + + private final PqAnchorConfig config; + private final PqSignerRegistry registry; + private final QbftExtraDataCodec extraDataCodec = new QbftExtraDataCodec(); + + /** + * Instantiates the anchor seals rule against the production Falcon registry. + * + * @param config the height-indexed activation configuration + */ + public PqAnchorSealsRule(final PqAnchorConfig config) { + this(config, PqSignerRegistry.falconSealSupport()); + } + + /** + * Instantiates the anchor seals rule against an explicit registry. + * + * @param config the height-indexed activation configuration + * @param registry the index-to-signer view used for eligibility and verification + */ + public PqAnchorSealsRule(final PqAnchorConfig config, final PqSignerRegistry registry) { + this.config = config; + this.registry = registry; + } + + @Override + public boolean validate( + final BlockHeader header, final BlockHeader parent, final ProtocolContext protocolContext) { + final long number = header.getNumber(); + + // HEIGHT GATE FIRST, same contract as R1: below H this rule does nothing at all, and from + // 2026-08-07 it also does nothing at a height that carries no certificate. + // + // BOTH RULES ASK THE SAME QUESTION, THROUGH THE SAME METHOD, and that is deliberate. + // anchorAppliesAt() = activeAt() AND isAnchorHeight(). If R1 and R2 each asked it their own + // way, a header accepted by one and refused by the other would be a chain break, appearing at + // exactly the first height that is not an anchor height, i.e. the day after activation. + if (!config.anchorAppliesAt(number)) { + return true; + } + + // SECOND GATE, and the SAME method R1 asks, for the same reason both rules share + // anchorAppliesAt: if the two rules each decided this boundary their own way, a header accepted + // by one and refused by the other would be a chain break. Inside a named window the threshold + // was not in force when the header was written and the indices were written in arrival order, + // so neither can be required of it now. + if (PqAnchorLapse.isDisarmed(number)) { + return acceptAsWrittenInsideLapse(header, number); + } + + try { + if (parent == null) { + LOG.warn( + "AERE PQ ANCHOR (R2): block {} REJECTED - no parent header available, so the " + + "certificate cannot be checked against the parent it claims to attest to.", + number); + return false; + } + // The certificate attests to the parent by NUMBER and HASH, and R1 has already bound the + // digest to header.getParentHash(). If the parent handed to this rule were a different header, + // R1 and R2 would be talking about two different objects. Cheap, and it removes any dependence + // on rule ordering with AncestryValidationRule. + if (!parent.getHash().equals(header.getParentHash()) + || parent.getNumber() != number - 1L) { + LOG.warn( + "AERE PQ ANCHOR (R2): block {} REJECTED - the supplied parent (number {}, hash {}) is " + + "not the parent this header names (number {}, hash {}).", + number, + parent.getNumber(), + parent.getHash(), + number - 1L, + header.getParentHash()); + return false; + } + + // AERE D-078 (2026-08-02). DIAGNOSTIC ONLY, and deliberately before every early return below + // so it runs at every height from H, including the warm-up where K is 0 and the certificate is + // empty. It feeds FalconSealSupport's registry-coverage report, whose only previous source was + // FalconSealValidationRule - a rule that stands down at exactly the height THIS rule takes + // over. Above H nothing updated it, so the coverage report was either frozen at a set from + // below H or, on a node whose process started above H, never set at all. Nothing here can + // change this rule's verdict: the call cannot throw and its result is not read. + observeValidatorsForDiagnostics(parent, protocolContext); + + final BftExtraData extraData = extraDataCodec.decodeRaw(header.getExtraData()); + // AERE ANCHOR V2 (2026-09-03): one form per height, both directions refused (see R1). + if (config.anchorV2AppliesAt(number)) { + return judgeV2(number, parent, extraData, protocolContext); + } + if (!extraData.getHybridSeals().isEmpty()) { + LOG.warn( + "AERE PQ ANCHOR (R2): block {} REJECTED - carries a v2 (scheme-tagged) certificate below" + + " the v2 activation height {}.", + number, + config.anchorV2Block()); + return false; + } + final List certificate = List.copyOf(extraData.getFalconSeals()); + final int k = certificate.size(); + + // THE THRESHOLD THIS HEIGHT WAS ACTUALLY JUDGED BY. Normally the configured schedule. Inside + // a named historical range it is the LOWER of the schedule and the emergency ceiling that was + // in force when these headers were written, which is the same lever the fleet itself used at + // the time, replayed from a fixed range instead of from a runtime option. Asking a header for + // more than was asked of the proposer that wrote it refuses history that no node can + // reproduce; asking for the ceiling rather than for nothing keeps the strongest claim the + // range supports, and the ceiling is a measured floor, never zero. See PqAnchorLapse. + int required = config.minSealsAt(number); + final OptionalInt inForce = PqAnchorLapse.historicMinSeals(number); + if (inForce.isPresent() && inForce.getAsInt() < required) { + LOG.debug( + "AERE PQ ANCHOR (R2): block {} sits in the historical range {}, so the threshold " + + "applied is the {} that was in force then, not the {} the schedule asks for.", + number, + PqAnchorLapse.windowCovering(number).orElse(null), + inForce.getAsInt(), + required); + required = inForce.getAsInt(); + } + + if (k < required) { + LOG.warn( + "AERE PQ ANCHOR (R2): block {} REJECTED - the carried certificate has {} seal(s), " + + "below the threshold K={} in force at this height.", + number, + k, + required); + return false; + } + + // Height aware since 2026-08-11, same measured historical window as rule R1. See + // PqAnchor.hasAcceptableIndices for why the exception sits at VALIDATION and nowhere else, and + // for what it deliberately does NOT relax. + if (!PqAnchor.hasAcceptableIndices(certificate, number)) { + LOG.warn( + "AERE PQ ANCHOR (R2): block {} REJECTED - the certificate's validator indices are not " + + "strictly increasing.", + number); + return false; + } + + if (k == 0) { + // Legitimate only while K is 0, which is the warm-up stage that starts at H itself, where + // the parent predates the scheme and no certificate can exist. R1 has already checked that + // the vanity digest is the digest OF THE EMPTY certificate, so an empty certificate is not + // an unchecked one. + LOG.debug( + "AERE PQ ANCHOR (R2): block {} carries an empty certificate and K={} at this height.", + number, + required); + return true; + } + + final BftContext bftContext = protocolContext.getConsensusContext(BftContext.class); + final Collection

parentValidators = + bftContext.getValidatorProvider().getValidatorsForBlock(parent); + if (parentValidators == null || parentValidators.isEmpty()) { + LOG.warn( + "AERE PQ ANCHOR (R2): block {} REJECTED - the validator set of the parent block {} " + + "could not be resolved, so seal eligibility cannot be decided.", + number, + parent.getNumber()); + return false; + } + final Set
eligible = new HashSet<>(parentValidators); + + final Bytes32 message = + PqAnchor.commitMessage( + config.chainId(), parent.getNumber(), parent.getHash().getBytes()); + + if (!falconSealsVerify(number, parent, eligible, message, certificate)) { + return false; + } + LOG.debug( + "AERE PQ ANCHOR (R2): block {} accepted with {} verified Falcon seal(s) over parent {} " + + "(threshold K={}, |parent validators|={}).", + number, + k, + parent.getNumber(), + required, + eligible.size()); + return true; + } catch (final Exception e) { + LOG.warn( + "AERE PQ ANCHOR (R2): block {} REJECTED - the certificate could not be checked: {}", + number, + e.toString()); + return false; + } + } + + /** + * Accept a header from a named unenforced window as it was written, after the one check that is + * not relaxed at any height. + * + *

The threshold is not applied here and neither is sortedness, because neither was applied + * when these headers were written; applying them now would refuse history the whole network + * holds. Distinctness IS applied, for the reason set out in {@link PqAnchor#hasAcceptableIndices} + * and in {@link PqAnchorLapse}: it is the part that is load bearing against an attacker rather + * than against ambiguity, and it is measured true on every header of the window. + * + * @param header the header being judged + * @param number its height + * @return true iff the carried certificate decodes and its indices are distinct and non-negative + */ + /** + * The Falcon half, shared by the v1 and the v2 form: every seal from an index the registry binds + * to a validator of the parent, no two indices on one address, each verifying over M(parent). + */ + private boolean falconSealsVerify( + final long number, + final BlockHeader parent, + final Set

eligible, + final Bytes32 message, + final List certificate) { + final Set
counted = new HashSet<>(); + for (final FalconSeal seal : certificate) { + final int index = seal.getValidatorIndex(); + // D-081: the key set is resolved AT THE PARENT'S HEIGHT, because that is the block these + // seals commit to. Resolving at this block's height would be wrong by one block, and at a + // rotation height "wrong by one block" is a different key set and a permanent chain stop. + // D2 (b-v2): the HISTORY door. This rule judges a header this node received, at the + // PARENT's height, which is the height the certificate commits to. It must refuse a + // height it has no registry binding for rather than answer from its own head. + final Address signer = + registry.addressForIndexAtHistoric(parent.getNumber(), index); + if (signer == null) { + LOG.warn( + "AERE PQ ANCHOR (R2): block {} REJECTED - certificate carries index {}, which the " + + "registry does not bind to any validator address. An unbound index cannot be " + + "shown to be eligible, so it is refused rather than skipped.", + number, + index); + return false; + } + if (!eligible.contains(signer)) { + LOG.warn( + "AERE PQ ANCHOR (R2): block {} REJECTED - certificate carries index {} (address {}), " + + "which was NOT a validator of the parent block {}.", + number, + index, + signer, + parent.getNumber()); + return false; + } + if (!counted.add(signer)) { + LOG.warn( + "AERE PQ ANCHOR (R2): block {} REJECTED - two distinct registry indices, including " + + "{}, resolve to the same validator address {}. That would let one validator be " + + "counted twice despite strictly increasing indices.", + number, + index, + signer); + return false; + } + if (!registry.verifyAtHistoric( + parent.getNumber(), index, message, seal.getSignature())) { + LOG.warn( + "AERE PQ ANCHOR (R2): block {} REJECTED - the Falcon signature from index {} " + + "(address {}) does not verify over M(parent {}).", + number, + index, + signer, + parent.getNumber()); + return false; + } + } + return true; + } + + /** + * AERE ANCHOR V2: the scheme-tagged certificate. K Falcon seals verified exactly as in v1, and for + * EVERY extra scheme the scheme schedule names at the parent height, K seals that (a) come from an + * index also carrying a Falcon seal in this certificate and (b) verify under the hybrid registry's + * key for that index and scheme over the same M(parent). A scheme the schedule does not name is + * refused, a missing registry is refused: a node that cannot verify has not proved the header good. + */ + private boolean judgeV2( + final long number, + final BlockHeader parent, + final BftExtraData extraData, + final ProtocolContext protocolContext) { + final List seals = extraData.getHybridSeals(); + if (seals.isEmpty()) { + LOG.warn( + "AERE PQ ANCHOR (R2): block {} REJECTED - at or above the v2 activation height {} the header" + + " carries no scheme-tagged certificate.", + number, + config.anchorV2Block()); + return false; + } + final int required = config.minSealsAt(number); + final List falcon = new ArrayList<>(); + final Set falconIndices = new HashSet<>(); + for (final SchemeSeal s : seals) { + if (s.getSchemeWireId() == SealSchemes.FALCON_512.wireId()) { + falcon.add(new FalconSeal(s.getValidatorIndex(), s.getSignature())); + falconIndices.add(s.getValidatorIndex()); + } + } + if (falcon.size() < required) { + LOG.warn( + "AERE PQ ANCHOR (R2): block {} REJECTED - the v2 certificate has {} Falcon seal(s), below" + + " the threshold K={} in force at this height.", + number, + falcon.size(), + required); + return false; + } + final BftContext bftContext = protocolContext.getConsensusContext(BftContext.class); + final Collection
parentValidators = + bftContext.getValidatorProvider().getValidatorsForBlock(parent); + if (parentValidators == null || parentValidators.isEmpty()) { + LOG.warn( + "AERE PQ ANCHOR (R2): block {} REJECTED - the validator set of the parent block {} could" + + " not be resolved, so seal eligibility cannot be decided.", + number, + parent.getNumber()); + return false; + } + final Set
eligible = new HashSet<>(parentValidators); + final Bytes32 message = + PqAnchor.commitMessage(config.chainId(), parent.getNumber(), parent.getHash().getBytes()); + if (!falcon.isEmpty() && !falconSealsVerify(number, parent, eligible, message, falcon)) { + return false; + } + final HybridSealSupport hybrid = HybridSealSupport.instance(); + final Set requiredSchemes = new HashSet<>(); + hybrid.schedule().ifPresent(sch -> requiredSchemes.addAll(sch.schemesAt(parent.getNumber()))); + requiredSchemes.remove(SealSchemes.FALCON_512.id()); + final Map counted = new HashMap<>(); + for (final SchemeSeal s : seals) { + if (s.getSchemeWireId() == SealSchemes.FALCON_512.wireId()) { + continue; + } + final Optional scheme = SealSchemes.byWireId(s.getSchemeWireId()); + if (scheme.isEmpty() || !requiredSchemes.contains(scheme.get().id())) { + LOG.warn( + "AERE PQ ANCHOR (R2): block {} REJECTED - the v2 certificate carries a seal of scheme 0x{}," + + " which the scheme schedule does not name at the parent height {} (named: {}).", + number, + Integer.toHexString(s.getSchemeWireId() & 0xff), + parent.getNumber(), + requiredSchemes); + return false; + } + if (!falconIndices.contains(s.getValidatorIndex())) { + LOG.warn( + "AERE PQ ANCHOR (R2): block {} REJECTED - a {} seal from index {} is not bound to a Falcon" + + " seal of the same index in this certificate.", + number, + scheme.get().id(), + s.getValidatorIndex()); + return false; + } + final Optional registry = hybrid.registry(); + if (registry.isEmpty()) { + LOG.warn( + "AERE PQ ANCHOR (R2): block {} REJECTED - the certificate carries {} seals and this node" + + " has NO hybrid registry loaded, so it cannot verify them. A node that cannot" + + " verify has not proved the header good.", + number, + scheme.get().id()); + return false; + } + final Optional pk = registry.get().publicKey(s.getValidatorIndex(), scheme.get().id()); + if (pk.isEmpty()) { + LOG.warn( + "AERE PQ ANCHOR (R2): block {} REJECTED - the hybrid registry holds no {} key for index {}.", + number, + scheme.get().id(), + s.getValidatorIndex()); + return false; + } + if (!scheme.get().verifyRaw(pk.get(), message.toArray(), s.getSignature().toArray())) { + LOG.warn( + "AERE PQ ANCHOR (R2): block {} REJECTED - the {} signature from index {} does not verify" + + " over M(parent {}).", + number, + scheme.get().id(), + s.getValidatorIndex(), + parent.getNumber()); + return false; + } + counted.merge(scheme.get().id(), 1, Integer::sum); + } + for (final String schemeId : requiredSchemes) { + final int have = counted.getOrDefault(schemeId, 0); + if (have < required) { + LOG.warn( + "AERE PQ ANCHOR (R2): block {} REJECTED - the v2 certificate has {} valid {} seal(s), below" + + " the threshold K={} the schedule demands of every named scheme.", + number, + have, + schemeId, + required); + return false; + } + } + LOG.debug( + "AERE PQ ANCHOR (R2): block {} accepted with a {}-seal scheme-tagged certificate over parent" + + " {} (K={} per scheme, schemes={}).", + number, + seals.size(), + parent.getNumber(), + required, + requiredSchemes); + return true; + } + + private boolean acceptAsWrittenInsideLapse(final BlockHeader header, final long number) { + try { + final BftExtraData extraData = extraDataCodec.decodeRaw(header.getExtraData()); + final List certificate = List.copyOf(extraData.getFalconSeals()); + if (!PqAnchor.hasDistinctNonNegativeIndices(certificate)) { + LOG.warn( + "AERE PQ ANCHOR (R2): block {} REJECTED - it falls inside the historical range {} in " + + "which the anchor rules were not in force, but its certificate repeats a " + + "validator index or carries a negative one, which no header of that range does.", + number, + PqAnchorLapse.windowCovering(number).orElse(null)); + return false; + } + LOG.debug( + "AERE PQ ANCHOR (R2): block {} accepted as written with {} seal(s); it falls inside the " + + "historical range {} in which neither the threshold nor the ordering was in force.", + number, + certificate.size(), + PqAnchorLapse.windowCovering(number).orElse(null)); + return true; + } catch (final Exception e) { + LOG.warn( + "AERE PQ ANCHOR (R2): block {} REJECTED - it falls inside a historical unenforced range " + + "but its extraData could not be decoded: {}", + number, + e.toString()); + return false; + } + } + + /** + * AERE D-078: hand the parent's validator set to {@link FalconSealSupport} so its registry- + * coverage report is about the height the chain is actually at. Swallows everything: a rule that + * rejected a header because a diagnostic threw would be a worse defect than the one this repairs. + * + * @param parent the parent header + * @param protocolContext the protocol context + */ + private void observeValidatorsForDiagnostics( + final BlockHeader parent, final ProtocolContext protocolContext) { + try { + final Collection
validators = + protocolContext + .getConsensusContext(BftContext.class) + .getValidatorProvider() + .getValidatorsForBlock(parent); + if (validators != null && !validators.isEmpty()) { + FalconSealSupport.instance().observeValidators(parent.getNumber(), validators); + } + } catch (final Exception e) { + LOG.debug( + "AERE PQ ANCHOR (R2): validator-set observation skipped at parent {}: {}", + parent.getNumber(), + e.toString()); + } + } + + @Override + public boolean includeInLightValidation() { + return false; + } + + @Override + public String toString() { + return "PqAnchorSeals(attached, full, " + + (config.everActive() ? "H=" + config.anchorBlock() : "INACTIVE") + + ", registry=" + + registry + + ")"; + } +} diff --git a/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqEmergencyShoutRule.java b/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqEmergencyShoutRule.java new file mode 100755 index 000000000..701f7a7a8 --- /dev/null +++ b/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqEmergencyShoutRule.java @@ -0,0 +1,134 @@ +/* + * Copyright contributors to Besu / AERE Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.qbft.headervalidationrules; + +import org.hyperledger.besu.consensus.common.bft.PqAnchorConfig; +import org.hyperledger.besu.ethereum.core.BlockHeader; +import org.hyperledger.besu.ethereum.mainnet.DetachedBlockHeaderValidationRule; + +import java.util.concurrent.atomic.AtomicLong; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * AERE OPTIUNI-URGENTA (2026-08-02): the NOISE half of the emergency controls. + * + *

The rule this exists to obey. A quiet way out is worse than no way out. An emergency + * control that silently degrades what a node checks will be set once at three in the morning by + * somebody who means to remove it in the morning, and will still be set six months later, on a fleet + * everybody believes is enforcing something it is not. So each of the three controls costs the + * operator a complete ERROR line at EVERY height for as long as it is in force: not a startup + * banner that scrolls away, not a metric nobody has a dashboard for, a line in the log of a running + * node that never stops. + * + *

Why a header rule. The alternative is a timer, and a timer would have to invent its own + * idea of the current height and would drift away from the code that actually decides anything. This + * rule is asked once per header by the same validator that decides whether the header may enter the + * chain, and it reads the SAME {@link PqAnchorConfig} object the anchor rules read, so what it + * announces cannot disagree with what is enforced. + * + *

It cannot reject anything. {@link #validate} returns true on every path, including its + * exception path. That is deliberate and it is what makes it safe to place FIRST in the ruleset: a + * rule that always returns true cannot change the verdict of a conjunction, so putting it first + * changes nothing except that the announcement is made even on headers a later rule goes on to + * reject - which is exactly when an operator most needs to know that a control was overridden. + * + *

It says nothing when nothing is overridden. No line is emitted at all when no control is + * in force, so "the log contains no AERE-PQC-EMG line" is a measurable statement about a node, and + * the silence is the control case that proves the shout is caused by the override and not by the + * rule merely existing. + * + *

The registry bypass announces itself from {@code FalconSealSupport}, on the same per-height + * basis, because only that class knows whether the bypass is actually carrying weight at a height. + */ +public class PqEmergencyShoutRule implements DetachedBlockHeaderValidationRule { + + private static final Logger LOG = LoggerFactory.getLogger(PqEmergencyShoutRule.class); + + private final PqAnchorConfig config; + + /** Highest height already announced, so one line is emitted per height and not per validation. */ + private final AtomicLong lastShout = new AtomicLong(-1L); + + /** + * Instantiates the emergency announcement rule. + * + * @param config the height-indexed anchor configuration the anchor rules are running on; the + * announcement is derived from THIS object, not from a second reading of the configuration + */ + public PqEmergencyShoutRule(final PqAnchorConfig config) { + this.config = config; + } + + @Override + public boolean validate(final BlockHeader header, final BlockHeader parent) { + try { + final long number = header.getNumber(); + + // A configured anchor that has been switched off is a different state from an anchor that was + // never configured. Only the first is worth waking somebody for. + final boolean disarmed = config.disabled() && config.anchorConfigured(); + final boolean lowered = config.emergencyCeilingLowersAt(number); + + if (!disarmed && !lowered) { + return true; + } + if (lastShout.getAndSet(number) == number) { + return true; + } + + if (disarmed) { + LOG.error( + "AERE PQC EMERGENCY [AERE-PQC-EMG-DISARM-01]: height {} validated WITH THE CERTIFICATE " + + "ANCHOR DISARMED. An activation height ({}) is configured on this node and both " + + "anchor rules have been switched off by {}, so this node is NOT checking that the " + + "post-quantum certificate in a header is the one the proposer wrote, and is NOT " + + "enforcing any seal threshold. A stripped or swapped certificate is invisible to " + + "this node. WHAT TO DO: this is a RECOVERY setting; once the cause is fixed, " + + "restart WITHOUT it, together with the rest of the fleet. THIS LINE REPEATS AT " + + "EVERY HEIGHT, BY DESIGN.", + number, + config.anchorBlock(), + PqAnchorConfig.PROPERTY_DISABLE); + } + if (lowered) { + LOG.error( + "AERE PQC EMERGENCY [AERE-PQC-EMG-CEILING-01]: height {} validated WITH A LOWERED SEAL " + + "THRESHOLD. The schedule in force asks for K={} at this height; the emergency " + + "ceiling {} caps it, so the threshold actually enforced is K={}. Fewer " + + "post-quantum signatures stand behind every block accepted while this is set. The " + + "ceiling can only ever LOWER the threshold, never raise it, so this cannot be the " + + "cause of a stall - but it is the reason a stall stopped. WHAT TO DO: raise the " + + "value back, or remove the option, as soon as enough validators can seal again. " + + "THIS LINE REPEATS AT EVERY HEIGHT, BY DESIGN.", + number, + config.scheduledMinSealsAt(number), + config.minSealsCeiling().isPresent() ? config.minSealsCeiling().getAsInt() : -1, + config.minSealsAt(number)); + } + return true; + } catch (final RuntimeException e) { + // A rule whose only job is to talk must never be the reason a header is refused. + LOG.warn("AERE PQC EMERGENCY: the emergency announcement itself failed: {}", e.toString()); + return true; + } + } + + @Override + public boolean includeInLightValidation() { + return true; + } +} diff --git a/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqRegistryBindingRule.java b/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqRegistryBindingRule.java new file mode 100755 index 000000000..84b7d98fb --- /dev/null +++ b/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqRegistryBindingRule.java @@ -0,0 +1,90 @@ +/* + * Copyright contributors to Besu / AERE Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.qbft.headervalidationrules; + +import org.hyperledger.besu.consensus.common.bft.FalconSealSupport; +import org.hyperledger.besu.ethereum.core.BlockHeader; +import org.hyperledger.besu.ethereum.mainnet.DetachedBlockHeaderValidationRule; + +/** + * A8, the PER-BLOCK half of the registry binding. + * + *

The hole this closes, stated as the measurement that found it. The A8 startup guard asks + * "does the registry on this node match what genesis requires" exactly once, against the chain head + * that existed when the process started. {@code config.pqRegistryHash} is a SCHEDULE, so it can have + * a second entry: a key rotation, or a validator-set change that re-anchors the manifest. A node + * that was already running when such a height passes underneath it is never asked the question + * again. It keeps verifying certificates against a registry the chain has moved off, and it keeps + * reporting itself healthy while it does so. That is defect A8 arriving through a different door, + * and the honest limits section of the A8 dossier said so in writing: "the guard runs at startup; it + * cannot stop a node that is already running when a rotation height passes underneath it." + * + *

Why a header rule and not a background timer. A timer would have to invent its own + * notion of "the current height" and its own reaction, and would be a second, drifting source of + * truth about a consensus question. This rule is asked exactly once per header, by the code that + * already decides whether a header may enter this node's chain, and it answers with the same + * function the startup guard uses, over the same loaded registry object. There is one decision and + * one implementation of it. + * + *

What it does on mismatch, and why refusing is the fail-closed direction. It returns + * false, so the header is not imported, and {@code FalconSealSupport} emits one complete ERROR line + * per height naming the hash required, the height it is required from, the hash found, the file in + * use and a fingerprint per registry row. Refusing looks harsh, and the alternative is worse: a node + * that imports a header whose certificate it cannot correctly verify has asserted a check it did not + * perform, which is precisely the behaviour the whole A8 repair exists to remove. Stopping at the + * first header past the rotation, loudly, is recoverable in one restart; importing 200,000 blocks + * under the wrong registry is not. + * + *

Inert unless somebody armed it, three times over. The rule returns true when the startup + * guard never ran on this node, when genesis carries no {@code config.pqRegistryHash} at all, and at + * every height below the schedule's first entry. Chain 2800 as it stands today satisfies all three: + * its genesis has no such key, measured on the live genesis file. So a binary carrying this rule + * validates the existing 11.8 million blocks exactly as today. + * + *

DETACHED. And the light-validation flag does NOT keep it off the sync path - that claim + * was here and it was wrong. Detached because the question is about this node's configuration + * at a height, not about the parent header. {@code includeInLightValidation()} returns false below, + * and the sentence that used to stand here said that this kept the rule away from a node that is + * fast-syncing headers. READ IN UPSTREAM BESU, and it does not: + * {@code BlockHeaderValidator.validateHeader} filters on {@code Rule::isDetachedSupported} alone + * for {@code DETACHED_ONLY}; only the three LIGHT modes consult {@code includeInLightValidation}. + * {@code DETACHED_ONLY} is exactly the mode {@code DownloadHeaderSequenceTask} validates + * closed-ended header ranges under, so this rule DOES run on the header-download path. + * + *

What that means, stated no further than it was checked: a syncing node whose registry does not + * satisfy the entry active at a height inside a downloaded range fails that whole range, and + * upstream treats a failed range as a bad peer. Whether that costs the node its peer table, and + * what it looks like when every honest peer serves the same headers, is NOT MEASURED. + * + *

The startup guard is what covers a node at the moment it is configured to matter, and since + * AERE CONSENS-LENS (2026-08-02) it asks about chainHead + 1, i.e. about the same height this rule + * will be asked about first. Before that change the two disagreed by one block, and that one block + * was a permanent chain stop at any scheduled rotation. + */ +public class PqRegistryBindingRule implements DetachedBlockHeaderValidationRule { + + /** Default constructor. */ + public PqRegistryBindingRule() {} + + @Override + public boolean validate(final BlockHeader header, final BlockHeader parent) { + return FalconSealSupport.instance().registryBindingSatisfiedAt(header.getNumber()); + } + + @Override + public boolean includeInLightValidation() { + return false; + } +} diff --git a/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/QbftAnchorRuleWiringTest.java b/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/QbftAnchorRuleWiringTest.java new file mode 100755 index 000000000..54ec478c0 --- /dev/null +++ b/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/QbftAnchorRuleWiringTest.java @@ -0,0 +1,246 @@ +/* + * AERE D-148, 2026-08-07. The WIRING of the anchor rules into the validation chain. + * + * WHY THIS EXISTS, and it is the very gap CLAUDE.md names as the most expensive one: "a gate that + * has never failed cannot be believed". + * + * The three rules are registered in QbftBlockHeaderValidationRulesetFactory. The rules themselves + * have good and plentiful tests: 18 in PqAnchorDigestRuleTest, 25 in PqAnchorSealsRuleTest. But ALL + * of them build the validator themselves, with + * `new BlockHeaderValidator.Builder().addRule(rule).build()`, that is, a validator holding a SINGLE + * rule, hand-made inside the test. Measured on 2026-08-07: ZERO tests in the whole qbft tree went + * through the factory. + * + * THE CONSEQUENCE, concretely: delete the line that adds PqAnchorSealsRule and all 43 tests stay + * GREEN. The rule keeps working perfectly in isolation and is never called on a real header again. + * Without this class the wiring is an assertion, not a measurement, and the only thing holding + * it up was a run log from a day ago. Yesterday's log does not guard tomorrow's code. + * + * HOW IT MEASURES. It builds the validator THROUGH THE FACTORY and reads the rules out of the + * object that was built, by reflection over its fields, without assuming their names. Reflection + * is a technique already used in this module (see PqInertBinaryTest and the reason given there), + * and it fits here because the question really is "what ended up inside", not "what the rule does". + * + * THE NEGATIVE CONTROL, and it lives in this same class: with an UNARMED configuration the same + * three rules must still be there. They switch themselves off ON HEIGHT, not by being absent from + * the chain. If somebody "optimised" the factory into not adding them when the anchor is not + * configured, a node would start without them and would then reach the activation height with + * nothing to apply. + */ +package org.hyperledger.besu.consensus.qbft; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.lang.reflect.Field; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.OptionalInt; +import org.hyperledger.besu.consensus.common.bft.PqAnchorConfig; +import org.junit.jupiter.api.Test; + +class QbftAnchorRuleWiringTest { + + private static final long H = 13_793_219L; + + /** The simple names of the classes the factory MUST put into the validator. */ + private static final List ANCHOR_RULES = + List.of("PqAnchorDigestRule", "PqAnchorDigestAttachedRule", "PqAnchorSealsRule"); + + private static PqAnchorConfig armed() { + return new PqAnchorConfig(2800L, H, Map.of(H, 3), OptionalInt.empty(), false); + } + + /** + * The simple names of every rule the built validator carries, whatever the fields holding them + * happen to be called. The object is read; its shape is not assumed. + */ + private static List rulesInside(final Object validator) throws Exception { + final List found = new ArrayList<>(); + for (Class c = validator.getClass(); c != null && c != Object.class; c = c.getSuperclass()) { + for (final Field f : c.getDeclaredFields()) { + f.setAccessible(true); + final Object value = f.get(validator); + if (value instanceof Collection collection) { + for (final Object element : collection) { + collectNames(element, found); + } + } + } + } + return found; + } + + /** + * The name of the element AND of the objects it holds in its own fields, one level down. + * + *

MEASURED 2026-08-07, and the first form of this test failed exactly here: {@code + * BlockHeaderValidator} does not hold the rules directly, it WRAPS them, and its collection holds + * nothing but objects whose simple name is {@code "Rule"}. A test that had stopped at the first + * level would have reported fifteen {@code "Rule"} and nothing else, meaning it would have been + * green on anything and red on anything, whichever way it happened to fall. We go down one level, + * and only one: what this test looks for is the rule the factory put in, not the whole object + * graph. + */ + private static void collectNames(final Object element, final List into) throws Exception { + if (element == null) { + return; + } + into.add(element.getClass().getSimpleName()); + for (final Field f : element.getClass().getDeclaredFields()) { + f.setAccessible(true); + final Object inner = f.get(element); + if (inner == null || f.getType().isPrimitive()) { + continue; + } + final String name = inner.getClass().getSimpleName(); + into.add(name); + // THE THIRD LEVEL, and only for lambdas. Measured 2026-08-07, the second form of this test: + // the ATTACHED rules show up directly (PqAnchorSealsRule, PqAnchorDigestAttachedRule), but + // the DETACHED ones do not show up at all: the builder wraps them in an adapter lambda, and + // all that could be seen was "BlockHeaderValidator$Builder$$Lambda/0x...". So + // PqAnchorDigestRule, the anchor's only detached rule, was invisible, and a test happy with + // what it could see would have declared missing a rule that was right there. + // + // We descend ONLY into the lambda, and not into every object, so that the test does not turn + // into a walk through the whole graph, where it would find anything and could no longer tell + // anything apart. + if (name.contains("Lambda")) { + for (final Field captured : inner.getClass().getDeclaredFields()) { + captured.setAccessible(true); + final Object deep = captured.get(inner); + if (deep != null) { + into.add(deep.getClass().getSimpleName()); + } + } + } + } + } + + private static Object buildViaFactory(final PqAnchorConfig cfg) { + return QbftBlockHeaderValidationRulesetFactory.blockHeaderValidator( + Duration.ofSeconds(1), false, Optional.empty(), cfg) + .build(); + } + + // --------------------------------------------------------------------------------------------- + // 1. The three rules are REALLY inside the validator the factory produces. + // This is the assertion that turns red if somebody deletes a line from the factory. + // --------------------------------------------------------------------------------------------- + @Test + void theFactoryPutsAllThreeAnchorRulesIntoTheValidatorItBuilds() throws Exception { + final List inside = rulesInside(buildViaFactory(armed())); + + assertThat(inside) + .describedAs( + "the rules found inside the validator the factory built; if one is missing, somebody " + + "removed an addRule and no other test in the tree would have noticed") + .containsAll(ANCHOR_RULES); + } + + // --------------------------------------------------------------------------------------------- + // 2. THE NEGATIVE CONTROL OF THIS TEST ITSELF. If the reflective reader returned an empty list + // for any reason at all, point 1 would have passed on anything at all. So we also demand that + // the validator carry upstream rules, which have nothing to do with the anchor. + // --------------------------------------------------------------------------------------------- + @Test + void theReaderActuallySeesSomethingAndNotAnEmptyList() throws Exception { + final List inside = rulesInside(buildViaFactory(armed())); + + assertThat(inside) + .describedAs("the reflective reader does see rules, otherwise the test above is empty") + .isNotEmpty(); + assertThat(inside.size()) + .describedAs("a QBFT validator carries far more than the three anchor rules") + .isGreaterThan(ANCHOR_RULES.size()); + } + + // --------------------------------------------------------------------------------------------- + // 3. UNARMED, the rules are STILL there. They switch off on height, not by being absent from the + // chain. A node that started without them would reach the activation height with nothing to + // apply, and the difference would not show until that very day. + // --------------------------------------------------------------------------------------------- + @Test + void theRulesArePresentEvenWhenTheAnchorIsNotConfigured() throws Exception { + final List inside = rulesInside(buildViaFactory(PqAnchorConfig.never(2800L))); + + assertThat(inside) + .describedAs( + "the rules switch off on HEIGHT, not by absence; an unarmed node must carry them " + + "anyway, so that it has them at the activation height") + .containsAll(ANCHOR_RULES); + } + + // --------------------------------------------------------------------------------------------- + // 4. And the three-argument overload, the upstream one production code calls, ends at the same + // result. Without this we would have measured only the path the test itself uses. + // --------------------------------------------------------------------------------------------- + @Test + void theUpstreamThreeArgumentOverloadWiresTheAnchorRulesToo() throws Exception { + final Object validator = + QbftBlockHeaderValidationRulesetFactory.blockHeaderValidator( + Duration.ofSeconds(1), false, Optional.empty()) + .build(); + + assertThat(rulesInside(validator)) + .describedAs( + "the 3-argument overload is the one production calls; if it bypasses the anchor, " + + "nothing else matters") + .containsAll(ANCHOR_RULES); + } + + // --------------------------------------------------------------------------------------------- + // 5. AERE D-AMONTE-02: the base-fee import rule is REALLY in the ruleset the factory builds. + // It is DETACHED, so without the third, lambda-only level of the reader above it would be + // invisible, exactly as PqAnchorDigestRule was on 2026-08-07. Disarmed-by-default: presence + // in the ruleset is precisely what must survive, because the rule switches on by HEIGHT. + // --------------------------------------------------------------------------------------------- + @Test + void theFactoryWiresTheBaseFeeImportRule() throws Exception { + final List inside = + rulesInside( + QbftBlockHeaderValidationRulesetFactory.blockHeaderValidator( + Duration.ofSeconds(1), + false, + Optional.of( + org.hyperledger.besu.ethereum.mainnet.feemarket.FeeMarket.london(0)), + armed()) + .build()); + + assertThat(inside) + .describedAs( + "the base-fee import rule the QBFT factory upstream forgot; if it is missing, somebody " + + "removed the addRule and the one-wei empty-block divergence is back") + .contains("AereBaseFeeImportRule"); + // positive control of the method ON THE SAME CLASS of rule: another detached upstream rule + // must be visible through the same lambda peek, otherwise the assertion above proves nothing + assertThat(inside).contains("AncestryValidationRule"); + } + + // --------------------------------------------------------------------------------------------- + // 6. AERE D-AMONTE-02: arming the property while the chain has NO fee market refuses at + // FACTORY time (node startup), through the real production path, never silently. + // --------------------------------------------------------------------------------------------- + @Test + void armingTheBaseFeeRuleWithoutAFeeMarketRefusesAtFactoryTime() { + try { + System.setProperty( + org.hyperledger.besu.consensus.qbft.headervalidationrules.AereBaseFeeImportRule + .PROPERTY_FORK_BLOCK, + "100"); + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> + QbftBlockHeaderValidationRulesetFactory.blockHeaderValidator( + Duration.ofSeconds(1), false, Optional.empty(), armed())) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("AERE-BASEFEE-VALIDATE-CONF-02"); + } finally { + System.clearProperty( + org.hyperledger.besu.consensus.qbft.headervalidationrules.AereBaseFeeImportRule + .PROPERTY_FORK_BLOCK); + } + } +} diff --git a/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/AereBaseFeeImportRuleTest.java b/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/AereBaseFeeImportRuleTest.java new file mode 100755 index 000000000..a37f63f86 --- /dev/null +++ b/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/AereBaseFeeImportRuleTest.java @@ -0,0 +1,142 @@ +/* AERE D-AMONTE-02, the base-fee import rule's proofs. + * + * The key case REPRODUCES divergence 1bis measured on mixed network 91777: an EMPTY parent at + * the 1 Gwei floor, an EMPTY child claiming floor plus ONE WEI. Nethermind rejected it, Besu + * swallowed it; with the rule armed, Besu rejects it too. The fee market is the REAL + * production one (LondonFeeMarket), with the floor armed through the VERY production path: + * system properties read at fee-market construction, set and cleaned in try/finally (the + * order-dependent-green lesson). */ +package org.hyperledger.besu.consensus.qbft.headervalidationrules; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.hyperledger.besu.datatypes.Wei; +import org.hyperledger.besu.ethereum.core.BlockHeader; +import org.hyperledger.besu.ethereum.core.BlockHeaderTestFixture; +import org.hyperledger.besu.ethereum.mainnet.feemarket.BaseFeeMarket; +import org.hyperledger.besu.ethereum.mainnet.feemarket.FeeMarket; + +import java.util.Optional; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +public class AereBaseFeeImportRuleTest { + + private static final long H_ARMING = 10_000L; + private static final long FLOOR = 1_000_000_000L; // 1 Gwei, chiar valoarea de pe 2800 + private static final long GAZ_LIMITA = 30_000_000L; + + @AfterEach + public void curata() { + System.clearProperty("aere.basefee.floor.forkBlock"); + System.clearProperty("aere.basefee.floor.value"); + System.clearProperty(AereBaseFeeImportRule.PROPERTY_FORK_BLOCK); + } + + /** The REAL fee market, with the AERE floor armed from block 0 through the production path. */ + private BaseFeeMarket feeMarketWithFloor() { + try { + System.setProperty("aere.basefee.floor.forkBlock", "0"); + System.setProperty("aere.basefee.floor.value", String.valueOf(FLOOR)); + return FeeMarket.london(0); + } finally { + System.clearProperty("aere.basefee.floor.forkBlock"); + System.clearProperty("aere.basefee.floor.value"); + } + } + + private BlockHeader header(final long numar, final long taxa, final long gazFolosit) { + return new BlockHeaderTestFixture() + .number(numar) + .baseFeePerGas(Wei.of(taxa)) + .gasLimit(GAZ_LIMITA) + .gasUsed(gazFolosit) + .buildHeader(); + } + + // ------------------------------------------------------------------- dezarmat = amonte + + @Test + public void disarmedAcceptsAnyBaseFeeAnywhere() { + final AereBaseFeeImportRule rule = + new AereBaseFeeImportRule(AereBaseFeeImportRule.DISARMED, Optional.of(feeMarketWithFloor())); + // empty block with a bogus fee: today it passes (the very defect), and disarmed it must pass the same + assertThat(rule.validate(header(H_ARMING, FLOOR + 1, 0), header(H_ARMING - 1, FLOOR, 0))) + .isTrue(); + } + + @Test + public void disarmedNeedsNoFeeMarket() { + // pe un lant pre-London, dezarmat, constructia nu are voie sa cada + assertThat(new AereBaseFeeImportRule(AereBaseFeeImportRule.DISARMED, Optional.empty())) + .isNotNull(); + } + + // ------------------------------------------------------------------- armat, sub si peste H + + @Test + public void belowArmingHeightHistoryIsUntouched() { + final AereBaseFeeImportRule rule = + new AereBaseFeeImportRule(H_ARMING, Optional.of(feeMarketWithFloor())); + // the same header wrong by 1 wei, but BELOW H: history (the floorless days, the + // lost-threshold window) must pass untouched + assertThat(rule.validate(header(H_ARMING - 1, FLOOR + 1, 0), header(H_ARMING - 2, FLOOR, 0))) + .isTrue(); + } + + @Test + public void armedAcceptsTheFlooredEmptyBlock() { + final AereBaseFeeImportRule rule = + new AereBaseFeeImportRule(H_ARMING, Optional.of(feeMarketWithFloor())); + // empty parent at the floor -> EIP-1559 would decrease, the floor holds the fee at 1 Gwei: the live chain itself + assertThat(rule.validate(header(H_ARMING, FLOOR, 0), header(H_ARMING - 1, FLOOR, 0))).isTrue(); + } + + @Test + public void armedRejectsTheOneWeiEmptyBlockDivergence() { + final AereBaseFeeImportRule rule = + new AereBaseFeeImportRule(H_ARMING, Optional.of(feeMarketWithFloor())); + // REPRODUCEREA 1bis: bloc GOL, taxa podea+1. Nethermind o respingea, Besu o inghitea. + assertThat(rule.validate(header(H_ARMING, FLOOR + 1, 0), header(H_ARMING - 1, FLOOR, 0))) + .isFalse(); + } + + @Test + public void armedRejectsABelowFloorEmptyBlock() { + final AereBaseFeeImportRule rule = + new AereBaseFeeImportRule(H_ARMING, Optional.of(feeMarketWithFloor())); + // 875000000 = the very value the unpatched node wrote in the 17 July proof + assertThat(rule.validate(header(H_ARMING, 875_000_000L, 0), header(H_ARMING - 1, FLOOR, 0))) + .isFalse(); + } + + // ------------------------------------------------------------------- refuzurile zgomotoase + + @Test + public void armedWithoutFeeMarketRefusesAtConstruction() { + assertThatThrownBy(() -> new AereBaseFeeImportRule(H_ARMING, Optional.empty())) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("AERE-BASEFEE-VALIDATE-CONF-02"); + } + + @Test + public void brokenPropertyRefusesLoudly() { + try { + System.setProperty(AereBaseFeeImportRule.PROPERTY_FORK_BLOCK, "10,141,734"); + assertThatThrownBy(AereBaseFeeImportRule::armedFromSystemConfig) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("AERE-BASEFEE-VALIDATE-CONF-01"); + } finally { + System.clearProperty(AereBaseFeeImportRule.PROPERTY_FORK_BLOCK); + } + } + + @Test + public void absentPropertyMeansDisarmed() { + System.clearProperty(AereBaseFeeImportRule.PROPERTY_FORK_BLOCK); + assertThat(AereBaseFeeImportRule.armedFromSystemConfig()) + .isEqualTo(AereBaseFeeImportRule.DISARMED); + } +} diff --git a/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/FalconSealLogThrottleTest.java b/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/FalconSealLogThrottleTest.java new file mode 100755 index 000000000..298b43988 --- /dev/null +++ b/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/FalconSealLogThrottleTest.java @@ -0,0 +1,181 @@ +/* + * Copyright contributors to Besu / AERE Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.qbft.headervalidationrules; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.Test; + +/** + * AERE 2026-08-07: the LOG-ONLY summary must not write one INFO line per block. + * + *

MEASURED ON A LIVE NODE, and that is why this test exists. The rule wrote the summary at INFO + * on every imported block: 2295 lines in twenty minutes, about 165,000 a day per node, every + * one of them identical, {@code 0 of 0 seals, |eligible|=0, no-eligible-signers}. On the old binary + * the same twenty minutes had ZERO such lines, so the spam was entirely ours. Seven validators had + * just come out of a disk emergency. + * + *

The runbook for putting this binary on the seven has, as its own item 3, "zero new log lines + * compared with the old binary". This test is that item, for the part a unit test can hold. + */ +class FalconSealLogThrottleTest { + + private static final String STARE_A = "no-eligible-signers|0|0|0|7"; + private static final String STARE_B = "PQC-quorum-not-yet|3|5|7|7"; + + private static int numaraInfo( + final FalconSealValidationRule regula, final String state, final long dePeLa, final int cate) { + int n = 0; + for (int i = 0; i < cate; i++) { + if (regula.shouldLogAtInfo(state, dePeLa + i)) { + n++; + } + } + return n; + } + + @Test + void theFirstTimeIsAlwaysWritten() { + // Without this, an operator starting the node would NEVER see that the rule is alive. + final FalconSealValidationRule r = new FalconSealValidationRule(); + assertThat(r.shouldLogAtInfo(STARE_A, 12_742_475L)).isTrue(); + } + + @Test + void aThousandBlocksInTheSAMEStateWriteASingleLine() { + final FalconSealValidationRule r = new FalconSealValidationRule(); + // 1000 blocks, well below the heartbeat, so only the first one may come out at INFO + assertThat(numaraInfo(r, STARE_A, 1_000L, 1_000)).isEqualTo(1); + } + + @Test + void aStateChangeIsSEENOnTheBlockWhereItHappens() { + final FalconSealValidationRule r = new FalconSealValidationRule(); + numaraInfo(r, STARE_A, 1_000L, 500); + // exactly the block on which it changes, not the next one and not a heartbeat later + assertThat(r.shouldLogAtInfo(STARE_B, 1_500L)).isTrue(); + // and after the change it goes quiet again + assertThat(numaraInfo(r, STARE_B, 1_501L, 500)).isEqualTo(0); + } + + @Test + void andGoingBackToTheOldStateIsSEENToo() { + // A regression, that is falling OUT of quorum, matters at least as much as reaching it. + final FalconSealValidationRule r = new FalconSealValidationRule(); + r.shouldLogAtInfo(STARE_A, 1_000L); + r.shouldLogAtInfo(STARE_B, 1_001L); + assertThat(r.shouldLogAtInfo(STARE_A, 1_002L)).isTrue(); + } + + @Test + void theHeartbeatWritesOneLinePerWindow() { + final FalconSealValidationRule r = new FalconSealValidationRule(); + final long H = FalconSealValidationRule.LOG_HEARTBEAT_BLOCKS; + // 5 whole windows, one single state. Expected: the first line plus one per window. + int info = 0; + for (long b = 0; b < 5 * H; b++) { + if (r.shouldLogAtInfo(STARE_A, b)) { + info++; + } + } + assertThat(info).isEqualTo(5); + } + + @Test + void theReductionIsAtLeastAThousandfold() { + // The number that matters for the seven hosts, as a proof rather than a claim in a comment. + final FalconSealValidationRule r = new FalconSealValidationRule(); + final int blocuriPeZi = 165_000; // 86400 / 0.523 + final int info = numaraInfo(r, STARE_A, 1_000L, blocuriPeZi); + assertThat(info).isLessThanOrEqualTo(20); + assertThat(blocuriPeZi / Math.max(info, 1)).isGreaterThanOrEqualTo(1_000); + } + + @Test + void itDoesNotChokeWhenTheStateFlipsEveryBlock() { + // The bad case: if the state really does change on every block, the line MUST come out on every + // block. A throttle that smothered that too would hide exactly the moment we care about. + final FalconSealValidationRule r = new FalconSealValidationRule(); + int info = 0; + for (int i = 0; i < 100; i++) { + if (r.shouldLogAtInfo(i % 2 == 0 ? STARE_A : STARE_B, 1_000L + i)) { + info++; + } + } + assertThat(info).isEqualTo(100); + } + + @Test + void heightsArrivingOUTOFORDERDoNotTriggerTheHeartbeat() { + // Import runs on several threads, so heights do not always arrive in increasing order. A step + // backwards makes the difference negative; that must NOT pass the threshold and write an extra + // line. + final FalconSealValidationRule r = new FalconSealValidationRule(); + r.shouldLogAtInfo(STARE_A, 1_000_000L); + int info = 0; + for (long b = 999_999L; b > 999_899L; b--) { + if (r.shouldLogAtInfo(STARE_A, b)) { + info++; + } + } + assertThat(info).isZero(); + } + + @Test + void underManyThreadsThereIsNeitherAFloodNorSilence() throws Exception { + // The rule is called from EthScheduler-Workers-N, so concurrently. I do not ask for perfect + // accuracy under races, but I do ask for the two things that matter: no flood, and no SILENCE. + final FalconSealValidationRule r = new FalconSealValidationRule(); + final int fire = 8; + final int perFir = 2_000; + final AtomicInteger info = new AtomicInteger(); + final ExecutorService ex = Executors.newFixedThreadPool(fire); + final CountDownLatch start = new CountDownLatch(1); + final List sarcini = new ArrayList<>(); + for (int f = 0; f < fire; f++) { + final long baza = 1_000L + (long) f * perFir; + sarcini.add( + () -> { + try { + start.await(); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + for (int i = 0; i < perFir; i++) { + if (r.shouldLogAtInfo(STARE_A, baza + i)) { + info.incrementAndGet(); + } + } + }); + } + sarcini.forEach(ex::submit); + start.countDown(); + ex.shutdown(); + assertThat(ex.awaitTermination(30, TimeUnit.SECONDS)).isTrue(); + assertThat(info.get()).isGreaterThanOrEqualTo(1); + // 16,000 calls; with no throttle that would be 16,000 lines. We require under 1% even under + // races. + assertThat(info.get()).isLessThan(160); + } +} diff --git a/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/FalconSealValidationRuleRetirementTest.java b/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/FalconSealValidationRuleRetirementTest.java new file mode 100755 index 000000000..c03a9489b --- /dev/null +++ b/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/FalconSealValidationRuleRetirementTest.java @@ -0,0 +1,85 @@ +/* + * Copyright contributors to Besu / AERE Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.qbft.headervalidationrules; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.H; +import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.extraData; +import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.header; +import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.parentHeader; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.withSettings; + +import org.hyperledger.besu.ethereum.ProtocolContext; +import org.hyperledger.besu.ethereum.core.BlockHeader; + +import java.util.Collections; + +import org.apache.tuweni.bytes.Bytes32; +import org.junit.jupiter.api.Test; +import org.mockito.quality.Strictness; + +/** + * The retirement of the legacy log-only Falcon rule at H. + * + *

The retirement is only worth anything if the gate precedes EVERY state touch, otherwise a node + * past H would still be paying for a rule that contributes nothing and still reading historical + * world state it may not have. So the test does not merely assert the return value, which is {@code + * true} on both sides of the gate and therefore proves nothing on its own: it asserts that above the + * retirement height the protocol context is never touched at all, and that below it, it is. + */ +public class FalconSealValidationRuleRetirementTest { + + private ProtocolContext context() { + return mock(ProtocolContext.class, withSettings().strictness(Strictness.LENIENT)); + } + + private BlockHeader headerAt(final long number) { + return header( + number, parentHeader(number - 1L).getHash(), extraData(Bytes32.ZERO, Collections.emptyList())); + } + + @Test + public void theDefaultConstructorNeverRetires() { + assertThat(new FalconSealValidationRule().toString()).contains("retiresAt=NEVER"); + assertThat(new FalconSealValidationRule(H).toString()).contains("retiresAt=" + H); + } + + @Test + public void atAndAboveTheRetirementHeightNothingIsTouchedAtAll() { + final FalconSealValidationRule rule = new FalconSealValidationRule(H); + for (final long number : new long[] {H, H + 1L, H + 5000L}) { + final ProtocolContext protocolContext = context(); + assertThat(rule.validate(headerAt(number), parentHeader(number - 1L), protocolContext)) + .isTrue(); + verifyNoInteractions(protocolContext); + } + } + + @Test + public void belowTheRetirementHeightTheRuleStillRunsExactlyAsBefore() { + final FalconSealValidationRule rule = new FalconSealValidationRule(H); + final ProtocolContext protocolContext = context(); + // Log-only baseline: it returns true, as it always has, including on its own error paths. That + // is precisely why it is being replaced rather than promoted, and precisely why leaving it in + // place below H is safe. + assertThat(rule.validate(headerAt(H - 1L), parentHeader(H - 2L), protocolContext)).isTrue(); + verify(protocolContext, atLeastOnce()).getConsensusContext(any()); + } +} diff --git a/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorDigestRuleTest.java b/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorDigestRuleTest.java new file mode 100755 index 000000000..64ba7f307 --- /dev/null +++ b/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorDigestRuleTest.java @@ -0,0 +1,359 @@ +/* + * Copyright contributors to Besu / AERE Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.qbft.headervalidationrules; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.CHAIN_ID; +import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.H; +import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.extraData; +import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.header; +import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.honestHeader; +import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.parentHeader; +import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.seal; + +import org.hyperledger.besu.consensus.common.bft.BftExtraData; +import org.hyperledger.besu.consensus.common.bft.FalconSeal; +import org.hyperledger.besu.consensus.common.bft.PqAnchor; +import org.hyperledger.besu.consensus.common.bft.PqAnchorConfig; +import org.hyperledger.besu.consensus.qbft.QbftExtraDataCodec; +import org.hyperledger.besu.ethereum.core.BlockHeader; +import org.hyperledger.besu.ethereum.mainnet.BlockHeaderValidator; +import org.hyperledger.besu.ethereum.mainnet.HeaderValidationMode; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.OptionalInt; + +import org.apache.tuweni.bytes.Bytes; +import org.apache.tuweni.bytes.Bytes32; +import org.junit.jupiter.api.Test; + +/** + * R1, the anchor digest rule. + * + *

Two of these tests are the reason the whole V2 scheme exists, and both are asserted twice: once + * through the rule directly, and once through a real {@link BlockHeaderValidator} running in a LIGHT + * validation mode, because "caught in full validation" would not be good enough. + * + *

READ THE SCOPE OF THAT SECOND ASSERTION EXACTLY, because an earlier version of this comment did + * not, and said instead that a node which fast-syncs headers must catch these too. What the assertion + * says is narrower: when a {@link BlockHeaderValidator} is asked to validate this header in mode + * LIGHT, this rule runs and refuses. It does NOT say that any Besu sync path asks in that mode, and a + * green suite here is not evidence that a syncing node is protected. What protects a syncing node is + * measured elsewhere and lives in other files: the open-ended-range fix in {@code + * DownloadHeadersStep}, {@code PqAnchorDigestAttachedRule} on the attached side, and {@code + * PqAnchorSyncModeGuard} refusing to start outside FULL sync. + */ +public class PqAnchorDigestRuleTest { + + private final PqAnchorConfig armed = + new PqAnchorConfig(CHAIN_ID, H, Map.of(H, 0, H + 100L, 3), OptionalInt.empty(), false); + private final PqAnchorDigestRule rule = new PqAnchorDigestRule(armed); + private final QbftExtraDataCodec codec = new QbftExtraDataCodec(); + + private static final List QUORUM = List.of(seal(0), seal(1), seal(2), seal(3), seal(4)); + + // --------------------------------------------------------------------------------------------- + // The property that makes this binary safe to warm on a live node. + // --------------------------------------------------------------------------------------------- + + @Test + public void belowHTheRuleIsInertEvenOnHeadersItWouldOtherwiseReject() { + final BlockHeader parent = parentHeader(H - 2L); + // Vanity is the client version string this chain actually writes today, and there is no + // certificate at all: precisely the shape of all 11.8 million existing blocks. + final Bytes legacyVanity = + Bytes.fromHexString( + "0x000000000000626573752030302e302d646576656c6f702d7878787878787878"); + final BlockHeader legacyShaped = + header(H - 1L, parent.getHash(), extraData(legacyVanity, Collections.emptyList())); + assertThat(rule.validate(legacyShaped, parent)).isTrue(); + + // And a header just below H whose vanity is pure garbage relative to its certificate is still + // accepted, because below H the rule does not even decode. + final BlockHeader garbage = + header(H - 1L, parent.getHash(), extraData(Bytes32.random(), QUORUM)); + assertThat(rule.validate(garbage, parent)).isTrue(); + } + + @Test + public void anUnconfiguredAnchorIsInertAtEveryHeight() { + final PqAnchorDigestRule inert = new PqAnchorDigestRule(PqAnchorConfig.never(CHAIN_ID)); + final BlockHeader parent = parentHeader(H + 4L); + final BlockHeader tampered = + header(H + 5L, parent.getHash(), extraData(Bytes32.random(), QUORUM)); + assertThat(inert.validate(tampered, parent)).isTrue(); + } + + // --------------------------------------------------------------------------------------------- + // Honest headers. + // --------------------------------------------------------------------------------------------- + + @Test + public void acceptsAnHonestHeaderAtAndAboveH() { + final BlockHeader parent = parentHeader(H - 1L); + assertThat(rule.validate(honestHeader(H, parent.getHash(), QUORUM), parent)).isTrue(); + + final BlockHeader laterParent = parentHeader(H + 500L); + assertThat(rule.validate(honestHeader(H + 501L, laterParent.getHash(), QUORUM), laterParent)) + .isTrue(); + } + + @Test + public void acceptsAnEmptyCertificateAtTheActivationHeightItself() { + // Block H's parent predates the scheme, so C is legitimately empty and D is the digest of the + // empty list. "Explicitly empty" and "absent" are one object because of the codec's canonical + // round-trip gate, so there is no second acceptable byte string here. + final BlockHeader parent = parentHeader(H - 1L); + final BlockHeader block = honestHeader(H, parent.getHash(), Collections.emptyList()); + assertThat(rule.validate(block, parent)).isTrue(); + assertThat(codec.decodeRaw(block.getExtraData()).getFalconSeals()).isEmpty(); + } + + // --------------------------------------------------------------------------------------------- + // A1: the certificate is STRIPPED. Required by the mission to be caught in LIGHT validation. + // --------------------------------------------------------------------------------------------- + + @Test + public void aStrippedCertificateIsRejected() { + final BlockHeader parent = parentHeader(H + 9L); + final BlockHeader honest = honestHeader(H + 10L, parent.getHash(), QUORUM); + final BlockHeader stripped = stripCertificate(honest); + + // The tamper is real: the certificate is gone, and vanityData is untouched. + assertThat(codec.decodeRaw(honest.getExtraData()).getFalconSeals()).hasSize(5); + assertThat(codec.decodeRaw(stripped.getExtraData()).getFalconSeals()).isEmpty(); + assertThat(codec.decodeRaw(stripped.getExtraData()).getVanityData()) + .isEqualTo(codec.decodeRaw(honest.getExtraData()).getVanityData()); + + assertThat(rule.validate(honest, parent)).isTrue(); + assertThat(rule.validate(stripped, parent)).isFalse(); + } + + @Test + public void aStrippedCertificateIsCaughtInLightValidation() { + final BlockHeader parent = parentHeader(H + 9L); + final BlockHeader stripped = stripCertificate(honestHeader(H + 10L, parent.getHash(), QUORUM)); + assertRejectedInEveryLightMode(stripped, parent); + } + + // --------------------------------------------------------------------------------------------- + // A2: the certificate is REPLACED by an equally valid one over a different subset. + // Required by the mission to be caught in LIGHT validation. + // --------------------------------------------------------------------------------------------- + + @Test + public void anEquallyValidDifferentSubsetIsRejected() { + final BlockHeader parent = parentHeader(H + 9L); + final List subsetA = List.of(seal(0), seal(1), seal(2), seal(3), seal(4)); + final List subsetB = List.of(seal(0), seal(1), seal(2), seal(3), seal(5)); + final BlockHeader honest = honestHeader(H + 10L, parent.getHash(), subsetA); + final BlockHeader swapped = replaceCertificate(honest, subsetB); + + // Both certificates are well formed, both are the same size, both have strictly increasing + // indices, and the block hash pre-image does not contain either of them. Only D separates them. + assertThat(codec.decodeRaw(swapped.getExtraData()).getFalconSeals()).hasSize(5); + assertThat(PqAnchor.hasStrictlyIncreasingIndices(subsetB)).isTrue(); + + assertThat(rule.validate(honest, parent)).isTrue(); + assertThat(rule.validate(swapped, parent)).isFalse(); + } + + @Test + public void anEquallyValidDifferentSubsetIsCaughtInLightValidation() { + final BlockHeader parent = parentHeader(H + 9L); + final BlockHeader swapped = + replaceCertificate( + honestHeader(H + 10L, parent.getHash(), List.of(seal(0), seal(1), seal(2), seal(3), seal(4))), + List.of(seal(0), seal(1), seal(2), seal(3), seal(5))); + assertRejectedInEveryLightMode(swapped, parent); + } + + @Test + public void aSubstitutedSignatureUnderTheSameIndexIsRejected() { + // The subset is identical; only one signature's bytes differ. A verifier that only counted valid + // signatures would accept this; the digest does not. + final BlockHeader parent = parentHeader(H + 9L); + final List original = List.of(seal(0), seal(1), seal(2)); + final List substituted = + List.of(seal(0), seal(1), new FalconSeal(2, PqAnchorTestSupport.signature(2, 7))); + final BlockHeader swapped = + replaceCertificate(honestHeader(H + 10L, parent.getHash(), original), substituted); + assertThat(rule.validate(swapped, parent)).isFalse(); + } + + // --------------------------------------------------------------------------------------------- + // A3 / A4: order and duplication. + // --------------------------------------------------------------------------------------------- + + @Test + public void aReorderedCertificateIsRejected() { + final BlockHeader parent = parentHeader(H + 9L); + final BlockHeader honest = honestHeader(H + 10L, parent.getHash(), QUORUM); + final BlockHeader reordered = + replaceCertificate(honest, List.of(seal(4), seal(3), seal(2), seal(1), seal(0))); + assertThat(rule.validate(reordered, parent)).isFalse(); + assertRejectedInEveryLightMode(reordered, parent); + } + + @Test + public void aRepeatedIndexIsRejectedEvenWhenTheDigestIsRecomputedOverIt() { + // An attacker who controls the proposer could try to inflate k by repeating a seal AND writing + // the matching digest. The strictly increasing requirement refuses it before the digest is even + // compared, so inflation is unrepresentable rather than filtered. + final BlockHeader parent = parentHeader(H + 9L); + final List doubled = + List.of(seal(0), seal(1), new FalconSeal(1, PqAnchorTestSupport.signature(1, 3))); + final Bytes32 digest = PqAnchor.anchorDigest(CHAIN_ID, H + 9L, parent.getHash().getBytes(), doubled); + final BlockHeader forged = header(H + 10L, parent.getHash(), extraData(digest, doubled)); + assertThat(rule.validate(forged, parent)).isFalse(); + } + + // --------------------------------------------------------------------------------------------- + // Binding, and fail-closed behaviour. + // --------------------------------------------------------------------------------------------- + + @Test + public void theDigestIsBoundToTheParentThisHeaderNames() { + final BlockHeader parent = parentHeader(H + 9L); + final BlockHeader other = parentHeader(H + 9L + 1L); + final BlockHeader honest = honestHeader(H + 10L, parent.getHash(), QUORUM); + // Same certificate, same height, different parent hash: the digest no longer matches. + final BlockHeader rehomed = + header(H + 10L, other.getHash(), honest.getExtraData()); + assertThat(rule.validate(rehomed, other)).isFalse(); + } + + @Test + public void theDigestIsBoundToTheChainId() { + // A6: the 442807 proving chain runs the same binaries and can run the same Falcon keys. + final BlockHeader parent = parentHeader(H + 9L); + final List certificate = PqAnchor.sortedByIndex(QUORUM); + final Bytes32 foreignDigest = + PqAnchor.anchorDigest(442807L, H + 9L, parent.getHash().getBytes(), certificate); + final BlockHeader foreign = + header(H + 10L, parent.getHash(), extraData(foreignDigest, certificate)); + assertThat(rule.validate(foreign, parent)).isFalse(); + } + + @Test + public void aVanityFieldThatIsNotThirtyTwoBytesIsRejected() { + final BlockHeader parent = parentHeader(H + 9L); + final BlockHeader shortVanity = + header(H + 10L, parent.getHash(), extraData(Bytes.repeat((byte) 0x11, 31), QUORUM)); + assertThat(rule.validate(shortVanity, parent)).isFalse(); + } + + @Test + public void undecodableExtraDataIsRejectedRatherThanAccepted() { + // BELOW its fork height, and only there, the legacy Falcon rule returned true on every path + // including the exception path, so in the only regime this chain has ever run it never caught + // anything. At and after the fork block WITH an active registry it is instead BLOCKING and does + // return false, on four paths including the exception path (measured by reading + // FalconSealValidationRule: blocking = forkReached && registryActive, then four `return false` + // sites inside validate). That mode has never been armed on this chain and must not be armed at + // the same time as the V2 anchor. A rule that cannot compute its verdict has not proved the + // header good. + final BlockHeader parent = parentHeader(H + 9L); + final BlockHeader broken = + header(H + 10L, parent.getHash(), Bytes.fromHexString("0xdeadbeef")); + assertThat(rule.validate(broken, parent)).isFalse(); + + final BlockHeader empty = header(H + 10L, parent.getHash(), Bytes.EMPTY); + assertThat(rule.validate(empty, parent)).isFalse(); + } + + // --------------------------------------------------------------------------------------------- + // The light-validation property itself. + // --------------------------------------------------------------------------------------------- + + @Test + public void includeInLightValidationIsTrue() { + assertThat(rule.includeInLightValidation()).isTrue(); + } + + @Test + public void anHonestHeaderStillPassesEveryLightMode() { + // The negative control for assertRejectedInEveryLightMode: if the modes below rejected + // everything, the rejection tests would prove nothing. + final BlockHeader parent = parentHeader(H + 9L); + final BlockHeader honest = honestHeader(H + 10L, parent.getHash(), QUORUM); + final BlockHeaderValidator validator = + new BlockHeaderValidator.Builder().addRule(rule).build(); + for (final HeaderValidationMode mode : + List.of( + HeaderValidationMode.LIGHT_DETACHED_ONLY, + HeaderValidationMode.LIGHT, + HeaderValidationMode.DETACHED_ONLY, + HeaderValidationMode.FULL)) { + assertThat(validator.validateHeader(honest, parent, null, mode)) + .withFailMessage("honest header should pass in mode %s", mode) + .isTrue(); + } + } + + @Test + public void theRuleIsSkippedByTheModesThatSkipDetachedRules() { + // Stating the shape honestly: LIGHT_SKIP_DETACHED and SKIP_DETACHED exclude every detached rule, + // so R1 does not run there. Those modes are the "attached half" of a split validation whose + // other half already ran R1, which is why this is not a hole. + final BlockHeader parent = parentHeader(H + 9L); + final BlockHeader stripped = stripCertificate(honestHeader(H + 10L, parent.getHash(), QUORUM)); + final BlockHeaderValidator validator = + new BlockHeaderValidator.Builder().addRule(rule).build(); + assertThat( + validator.validateHeader( + stripped, parent, null, HeaderValidationMode.LIGHT_SKIP_DETACHED)) + .isTrue(); + assertThat(validator.validateHeader(stripped, parent, null, HeaderValidationMode.SKIP_DETACHED)) + .isTrue(); + } + + private void assertRejectedInEveryLightMode(final BlockHeader header, final BlockHeader parent) { + final BlockHeaderValidator validator = + new BlockHeaderValidator.Builder().addRule(rule).build(); + for (final HeaderValidationMode mode : + List.of( + HeaderValidationMode.LIGHT_DETACHED_ONLY, + HeaderValidationMode.LIGHT, + HeaderValidationMode.DETACHED_ONLY, + HeaderValidationMode.FULL)) { + assertThat(validator.validateHeader(header, parent, null, mode)) + .withFailMessage("tampered header should be rejected in mode %s", mode) + .isFalse(); + } + } + + private BlockHeader stripCertificate(final BlockHeader honest) { + return replaceCertificate(honest, Collections.emptyList()); + } + + private BlockHeader replaceCertificate( + final BlockHeader honest, final List certificate) { + final BftExtraData original = codec.decodeRaw(honest.getExtraData()); + final Bytes tampered = + codec.encode( + new BftExtraData( + original.getVanityData(), + original.getSeals(), + Optional.empty(), + original.getRound(), + original.getValidators(), + certificate)); + return header(honest.getNumber(), honest.getParentHash(), tampered); + } +} diff --git a/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorDisarmedWindowTest.java b/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorDisarmedWindowTest.java new file mode 100755 index 000000000..9451ec7b7 --- /dev/null +++ b/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorDisarmedWindowTest.java @@ -0,0 +1,548 @@ +/* + * Copyright contributors to Besu / Aere Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.qbft.headervalidationrules; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.CHAIN_ID; +import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.VALIDATORS; +import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.extraData; +import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.header; +import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.parentHeader; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.withSettings; + +import org.hyperledger.besu.consensus.common.bft.BftContext; +import org.hyperledger.besu.consensus.common.bft.FalconSeal; +import org.hyperledger.besu.consensus.common.bft.PqAnchor; +import org.hyperledger.besu.consensus.common.bft.PqAnchorConfig; +import org.hyperledger.besu.consensus.common.bft.PqAnchorLapse; +import org.hyperledger.besu.consensus.common.bft.PqSignerRegistry; +import org.hyperledger.besu.consensus.common.validator.ValidatorProvider; +import org.hyperledger.besu.datatypes.Address; +import org.hyperledger.besu.ethereum.ProtocolContext; +import org.hyperledger.besu.ethereum.core.BlockHeader; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.OptionalInt; + +import org.apache.tuweni.bytes.Bytes; +import org.apache.tuweni.bytes.Bytes32; +import org.junit.jupiter.api.Test; +import org.mockito.quality.Strictness; + +/** + * The two historical ranges in which the anchor rules were not fully in force, as the two rules see + * them. + * + *

The configuration here is the one the live chain runs, not the small synthetic one the other + * rule tests use, because the whole subject is a set of REAL heights: the activation height, the + * 32-block anchor spacing and the threshold schedule all have to be the production ones, or the + * heights under test are not anchor heights at all and every assertion below would pass on a rule + * that does nothing. {@link #everyHeightUnderTestIsAnAnchorHeightOfTheLiveConfiguration()} is the + * positive control for exactly that. + * + *

Every acceptance in this file is paired with the same header shape at a height where it must + * still be refused, and every refusal is paired with an honest header at that same height, which + * must still be accepted. Without the first pairing the tests would pass on a rule that accepts + * everything; without the second they would pass on a rule that refuses everything. + */ +public class PqAnchorDisarmedWindowTest { + + /** The live activation height H. */ + private static final long H_LIVE = 13_014_000L; + + /** The live anchor spacing. */ + private static final int INTERVAL = 32; + + /** The last anchor height fully under the rules before the interruption. */ + private static final long BEFORE = 13_267_792L; + + /** First height of the range in which nothing was in force. */ + private static final long OFF_FIRST = 13_267_824L; + + /** A height in the middle of that range. */ + private static final long OFF_MIDDLE = 13_268_016L; + + /** Last height of that range. */ + private static final long OFF_LAST = 13_268_944L; + + /** First height of the range in which only the threshold was lowered. */ + private static final long LOW_FIRST = 13_268_976L; + + /** A height in the middle of that range. */ + private static final long LOW_MIDDLE = 13_500_016L; + + /** Last height of that range. */ + private static final long LOW_LAST = 13_890_544L; + + /** The first anchor height fully under the rules again. */ + private static final long AFTER = 13_890_576L; + + /** + * The vanityData the headers of the first range actually carry: the client's version string, + * which is what a node writes when the anchor is switched off. Read off a live header of the + * range. + */ + private static final Bytes CLIENT_VANITY = + Bytes.fromHexString("0x00000000000000626573752032362e372d646576656c6f702d64323033323031"); + + /** Signer indices exactly as one header of the first range carries them: distinct, not sorted. */ + private static final List ARRIVAL_ORDER = List.of(2, 6, 1); + + /** A second measured arrival order, from another header of the same range. */ + private static final List ARRIVAL_ORDER_2 = List.of(1, 3, 6, 0); + + private final PqAnchorConfig live = + new PqAnchorConfig( + CHAIN_ID, + H_LIVE, + Map.of(H_LIVE, 0, 13_034_000L, 3), + OptionalInt.empty(), + false, + OptionalInt.empty(), + OptionalInt.of(INTERVAL)); + + private final LocalRegistry registry = new LocalRegistry(); + private final PqAnchorDigestRule r1 = new PqAnchorDigestRule(live); + private final PqAnchorSealsRule r2 = new PqAnchorSealsRule(live, registry); + + // ----------------------------------------------------------------------------------------------- + // The heights under test really are anchor heights. Without this the file measures nothing. + // ----------------------------------------------------------------------------------------------- + + @Test + public void everyHeightUnderTestIsAnAnchorHeightOfTheLiveConfiguration() { + for (final long height : + List.of(BEFORE, OFF_FIRST, OFF_MIDDLE, OFF_LAST, LOW_FIRST, LOW_MIDDLE, LOW_LAST, AFTER)) { + assertThat(live.anchorAppliesAt(height)) + .describedAs("height %d must be one the rules judge, or this file proves nothing", height) + .isTrue(); + assertThat(live.minSealsAt(height)) + .describedAs("the schedule asks for three seals at height %d", height) + .isEqualTo(3); + } + assertThat(PqAnchorLapse.isDisarmed(OFF_FIRST)).isTrue(); + assertThat(PqAnchorLapse.isDisarmed(OFF_LAST)).isTrue(); + assertThat(PqAnchorLapse.isDisarmed(BEFORE)).isFalse(); + assertThat(PqAnchorLapse.isDisarmed(LOW_FIRST)).isFalse(); + assertThat(PqAnchorLapse.historicMinSeals(LOW_FIRST)).hasValue(1); + assertThat(PqAnchorLapse.historicMinSeals(AFTER)).isEmpty(); + } + + // ----------------------------------------------------------------------------------------------- + // RANGE 1, nothing in force: a header of the shape the chain holds is accepted by both rules. + // ----------------------------------------------------------------------------------------------- + + @Test + public void r1AcceptsAHeaderOfTheFirstRangeAsWritten() { + for (final long height : List.of(OFF_FIRST, OFF_MIDDLE, OFF_LAST)) { + final BlockHeader parent = parentHeader(height - 1L); + final BlockHeader block = unenforcedHeader(height, parent, ARRIVAL_ORDER); + assertThat(r1.validate(block, parent)) + .describedAs( + "height %d carries the client version string in vanityData and a certificate in " + + "arrival order; that is what the chain holds, and a node syncing from genesis " + + "must be able to pass it", + height) + .isTrue(); + } + } + + @Test + public void r2AcceptsAHeaderOfTheFirstRangeAsWritten() { + for (final long height : List.of(OFF_FIRST, OFF_MIDDLE, OFF_LAST)) { + final BlockHeader parent = parentHeader(height - 1L); + final BlockHeader block = unenforcedHeader(height, parent, ARRIVAL_ORDER); + assertThat(r2.validate(block, parent, contextWith(VALIDATORS))) + .describedAs("height %d must pass the seals rule too, or the node stops 32 blocks on", height) + .isTrue(); + } + } + + @Test + public void bothRulesAcceptTheSecondMeasuredArrivalOrder() { + final BlockHeader parent = parentHeader(OFF_MIDDLE - 1L); + final BlockHeader block = unenforcedHeader(OFF_MIDDLE, parent, ARRIVAL_ORDER_2); + assertThat(r1.validate(block, parent)).isTrue(); + assertThat(r2.validate(block, parent, contextWith(VALIDATORS))).isTrue(); + } + + @Test + public void insideTheFirstRangeTheThresholdIsNotAppliedEither() { + // The threshold is one of the things that was switched off, so a header of this range may carry + // fewer seals than K. The schedule asks three; this certificate carries one. + final BlockHeader parent = parentHeader(OFF_FIRST - 1L); + final BlockHeader block = unenforcedHeader(OFF_FIRST, parent, List.of(4)); + assertThat(r2.validate(block, parent, contextWith(VALIDATORS))).isTrue(); + } + + @Test + public void insideTheFirstRangeAnHonestHeaderIsStillAccepted() { + // The exception must not break the ordinary case: a correctly written header that happens to + // fall inside the range is accepted too, so nothing has to be re-produced. + final BlockHeader parent = parentHeader(OFF_FIRST - 1L); + final BlockHeader block = honestBlock(OFF_FIRST, parent, List.of(0, 1, 2)); + assertThat(r1.validate(block, parent)).isTrue(); + assertThat(r2.validate(block, parent, contextWith(VALIDATORS))).isTrue(); + } + + // ----------------------------------------------------------------------------------------------- + // RANGE 2, only the threshold lowered: the digest and the ordering are STILL required, and the + // short certificate is accepted down to the floor that was really in force, and no further. + // ----------------------------------------------------------------------------------------------- + + @Test + public void insideTheSecondRangeTheDigestIsStillRequired() { + // This is the assertion that stops the first range's relaxation from leaking into the second. + // If it ever passes, the binding has been thrown away on 19,425 anchor heights. + for (final long height : List.of(LOW_FIRST, LOW_MIDDLE, LOW_LAST)) { + final BlockHeader parent = parentHeader(height - 1L); + final BlockHeader noDigest = unenforcedHeader(height, parent, List.of(0, 1, 2)); + assertThat(r1.validate(noDigest, parent)) + .describedAs("height %d is inside the lowered-threshold range, where the digest DID hold", height) + .isFalse(); + + // Positive control at the same height. + assertThat(r1.validate(honestBlock(height, parent, List.of(0, 1, 2)), parent)).isTrue(); + } + } + + @Test + public void insideTheSecondRangeTheOrderingIsStillRequired() { + final BlockHeader parent = parentHeader(LOW_MIDDLE - 1L); + final ProtocolContext context = contextWith(VALIDATORS); + final BlockHeader unsorted = digestBoundButUnsorted(LOW_MIDDLE, parent, ARRIVAL_ORDER); + assertThat(r2.validate(unsorted, parent, context)).isFalse(); + assertThat(r2.validate(honestBlock(LOW_MIDDLE, parent, List.of(1, 2, 6)), parent, context)) + .isTrue(); + } + + @Test + public void insideTheSecondRangeAShortCertificateIsAccepted() { + // Measured: 2,926 of the 19,425 anchor heights of this range carry one or two seals where the + // schedule asks three, because the fleet was running with the emergency ceiling lowered. + for (final long height : List.of(LOW_FIRST, LOW_MIDDLE, LOW_LAST)) { + final BlockHeader parent = parentHeader(height - 1L); + final ProtocolContext context = contextWith(VALIDATORS); + assertThat(live.minSealsAt(height)).isEqualTo(3); + + assertThat(r2.validate(honestBlock(height, parent, List.of(0, 1)), parent, context)) + .describedAs("two seals at height %d, the count actually written there", height) + .isTrue(); + assertThat(r2.validate(honestBlock(height, parent, List.of(5)), parent, context)) + .describedAs("one seal at height %d, the lowest count measured anywhere in the range", height) + .isTrue(); + } + } + + @Test + public void insideTheSecondRangeAnEmptyCertificateIsStillRefused() { + // The floor is ONE, not zero, because one is the ceiling that was actually in force and no + // header of the range carries zero. A floor of zero would accept an empty certificate where a + // certificate was in fact required, which is weaker than the history needs. + final BlockHeader parent = parentHeader(LOW_MIDDLE - 1L); + final ProtocolContext context = contextWith(VALIDATORS); + assertThat(r2.validate(honestBlock(LOW_MIDDLE, parent, List.of()), parent, context)).isFalse(); + assertThat(r2.validate(honestBlock(LOW_MIDDLE, parent, List.of(0)), parent, context)).isTrue(); + } + + @Test + public void insideTheSecondRangeAnInvalidSignatureIsStillRefused() { + // Only the COUNT moves. Every seal still has to verify, and still has to belong to a validator + // of the parent. + final BlockHeader parent = parentHeader(LOW_MIDDLE - 1L); + final ProtocolContext context = contextWith(VALIDATORS); + final List bad = List.of(new FalconSeal(0, Bytes.repeat((byte) 0x5a, 655))); + final Bytes32 digest = + PqAnchor.anchorDigest(CHAIN_ID, parent.getNumber(), parent.getHash().getBytes(), bad); + final BlockHeader block = header(LOW_MIDDLE, parent.getHash(), extraData(digest, bad)); + assertThat(r2.validate(block, parent, context)).isFalse(); + assertThat(r2.validate(honestBlock(LOW_MIDDLE, parent, List.of(0)), parent, context)).isTrue(); + } + + // ----------------------------------------------------------------------------------------------- + // OUTSIDE both ranges: nothing changed at all. + // ----------------------------------------------------------------------------------------------- + + @Test + public void outsideBothRangesAHeaderWithNoDigestIsStillRefusedByR1() { + for (final long height : List.of(BEFORE, LOW_FIRST, AFTER)) { + final BlockHeader parent = parentHeader(height - 1L); + final BlockHeader block = unenforcedHeader(height, parent, ARRIVAL_ORDER); + assertThat(r1.validate(block, parent)) + .describedAs( + "height %d is outside the range where nothing was in force, so a header with no " + + "digest must still stop this node; that is what a NEW lapse looks like", + height) + .isFalse(); + + final BlockHeader honest = honestBlock(height, parent, List.of(0, 1, 2)); + assertThat(r1.validate(honest, parent)) + .describedAs("an honest header at height %d is still accepted", height) + .isTrue(); + } + } + + @Test + public void outsideBothRangesTheSameShapeIsStillRefusedByR2() { + for (final long height : List.of(BEFORE, LOW_FIRST, AFTER)) { + final BlockHeader parent = parentHeader(height - 1L); + final ProtocolContext context = contextWith(VALIDATORS); + final BlockHeader block = unenforcedHeader(height, parent, ARRIVAL_ORDER); + assertThat(r2.validate(block, parent, context)) + .describedAs("height %d is outside the disarmed range, so unsorted indices are refused", height) + .isFalse(); + + final BlockHeader honest = honestBlock(height, parent, List.of(0, 1, 2)); + assertThat(r2.validate(honest, parent, context)) + .describedAs("an honest header at height %d is still accepted", height) + .isTrue(); + } + } + + @Test + public void afterBothRangesTheFullThresholdIsBack() { + // One anchor step past the end of the lowered range the schedule applies again, unchanged. + // This is the assertion that stops the lowered threshold from becoming permanent. + final BlockHeader parent = parentHeader(AFTER - 1L); + final ProtocolContext context = contextWith(VALIDATORS); + assertThat(live.minSealsAt(AFTER)).isEqualTo(3); + assertThat(r2.validate(honestBlock(AFTER, parent, List.of(0, 1)), parent, context)).isFalse(); + assertThat(r2.validate(honestBlock(AFTER, parent, List.of(0, 1, 2)), parent, context)).isTrue(); + } + + @Test + public void wellBeyondTheRangesNothingIsRelaxed() { + final long far = 14_077_008L; + assertThat(live.anchorAppliesAt(far)).isTrue(); + final BlockHeader parent = parentHeader(far - 1L); + final ProtocolContext context = contextWith(VALIDATORS); + assertThat(r1.validate(unenforcedHeader(far, parent, ARRIVAL_ORDER), parent)).isFalse(); + assertThat(r2.validate(honestBlock(far, parent, List.of(0, 1)), parent, context)).isFalse(); + assertThat(r2.validate(honestBlock(far, parent, List.of(0, 1, 2)), parent, context)).isTrue(); + } + + // ----------------------------------------------------------------------------------------------- + // The bounds are exact, to one anchor step and to one block. + // ----------------------------------------------------------------------------------------------- + + @Test + public void theBoundsAreExactToOneAnchorStep() { + assertThat(BEFORE + INTERVAL).isEqualTo(OFF_FIRST); + assertThat(OFF_LAST + INTERVAL).isEqualTo(LOW_FIRST); + assertThat(LOW_LAST + INTERVAL).isEqualTo(AFTER); + + final BlockHeader beforeParent = parentHeader(BEFORE - 1L); + assertThat(r1.validate(unenforcedHeader(BEFORE, beforeParent, ARRIVAL_ORDER), beforeParent)) + .isFalse(); + + final BlockHeader firstParent = parentHeader(OFF_FIRST - 1L); + assertThat(r1.validate(unenforcedHeader(OFF_FIRST, firstParent, ARRIVAL_ORDER), firstParent)) + .isTrue(); + + final BlockHeader lastParent = parentHeader(OFF_LAST - 1L); + assertThat(r1.validate(unenforcedHeader(OFF_LAST, lastParent, ARRIVAL_ORDER), lastParent)) + .isTrue(); + + final BlockHeader lowParent = parentHeader(LOW_FIRST - 1L); + assertThat(r1.validate(unenforcedHeader(LOW_FIRST, lowParent, ARRIVAL_ORDER), lowParent)) + .isFalse(); + + // And the far end of the lowered range, to one anchor step, on the threshold. + final BlockHeader lowLastParent = parentHeader(LOW_LAST - 1L); + final ProtocolContext context = contextWith(VALIDATORS); + assertThat(r2.validate(honestBlock(LOW_LAST, lowLastParent, List.of(0)), lowLastParent, context)) + .isTrue(); + final BlockHeader afterParent = parentHeader(AFTER - 1L); + assertThat(r2.validate(honestBlock(AFTER, afterParent, List.of(0)), afterParent, context)) + .isFalse(); + } + + @Test + public void theBoundsAreExactToOneBlockOnBothRules() { + // The heights either side of the bounds are not anchor heights, so both rules stand down there + // for the ordinary reason. Asserted so that "accepted" at OFF_FIRST - 1 is not mistaken for the + // exception having leaked one block down. + assertThat(live.anchorAppliesAt(OFF_FIRST - 1L)).isFalse(); + assertThat(live.anchorAppliesAt(OFF_LAST + 1L)).isFalse(); + assertThat(PqAnchorLapse.isDisarmed(OFF_FIRST - 1L)).isFalse(); + assertThat(PqAnchorLapse.isDisarmed(OFF_LAST + 1L)).isFalse(); + assertThat(PqAnchorLapse.historicMinSeals(LOW_LAST + 1L)).isEmpty(); + } + + // ----------------------------------------------------------------------------------------------- + // What the exception deliberately does NOT relax, at any height. + // ----------------------------------------------------------------------------------------------- + + @Test + public void insideTheFirstRangeARepeatedIndexIsStillRefusedByBothRules() { + // Sortedness fixes one accepted order for a given set; distinctness is what makes "repeat one + // seal to inflate the count" unrepresentable. Measured on every header of the range: distinct + // in all of them. So this refusal costs nothing and keeps the attack unrepresentable. + final BlockHeader parent = parentHeader(OFF_FIRST - 1L); + final BlockHeader repeated = unenforcedHeader(OFF_FIRST, parent, List.of(2, 6, 2)); + assertThat(r1.validate(repeated, parent)).isFalse(); + assertThat(r2.validate(repeated, parent, contextWith(VALIDATORS))).isFalse(); + + // Positive control: the same rule at the same height accepts the distinct version. + final BlockHeader distinct = unenforcedHeader(OFF_FIRST, parent, ARRIVAL_ORDER); + assertThat(r1.validate(distinct, parent)).isTrue(); + assertThat(r2.validate(distinct, parent, contextWith(VALIDATORS))).isTrue(); + } + + @Test + public void insideTheFirstRangeAnUndecodableHeaderIsStillRefused() { + final BlockHeader parent = parentHeader(OFF_FIRST - 1L); + final BlockHeader garbage = + header(OFF_FIRST, parent.getHash(), Bytes.fromHexString("0xdeadbeef")); + assertThat(r1.validate(garbage, parent)).isFalse(); + assertThat(r2.validate(garbage, parent, contextWith(VALIDATORS))).isFalse(); + } + + @Test + public void belowTheActivationHeightNothingIsJudgedAtAll() { + // The ranges sit far above H, so this only restates the height gate; it is here because a + // future edit that moved the range gates ABOVE the height gate would make the rules decode + // headers on the whole existing chain, which is the property that lets this binary be warmed. + final BlockHeader parent = parentHeader(H_LIVE - 2L); + final BlockHeader block = unenforcedHeader(H_LIVE - 1L, parent, ARRIVAL_ORDER); + assertThat(r1.validate(block, parent)).isTrue(); + assertThat(r2.validate(block, parent, contextWith(VALIDATORS))).isTrue(); + } + + // ----------------------------------------------------------------------------------------------- + // Helpers. + // ----------------------------------------------------------------------------------------------- + + /** + * A header of the shape the first range holds: the client version string in vanityData instead of + * a digest, and the certificate written in the order the commits arrived. + */ + private BlockHeader unenforcedHeader( + final long number, final BlockHeader parent, final List indicesInArrivalOrder) { + return header( + number, parent.getHash(), extraData(CLIENT_VANITY, sealsFor(parent, indicesInArrivalOrder))); + } + + /** A header whose digest is correctly bound but whose indices are left in arrival order. */ + private BlockHeader digestBoundButUnsorted( + final long number, final BlockHeader parent, final List indicesInArrivalOrder) { + final List seals = sealsFor(parent, indicesInArrivalOrder); + final Bytes32 digest = + PqAnchor.anchorDigest(CHAIN_ID, parent.getNumber(), parent.getHash().getBytes(), seals); + return header(number, parent.getHash(), extraData(digest, seals)); + } + + /** A header an honest proposer under the armed rules writes: digest bound, indices sorted. */ + private BlockHeader honestBlock( + final long number, final BlockHeader parent, final List indices) { + final List sorted = PqAnchor.sortedByIndex(sealsFor(parent, indices)); + final Bytes32 digest = + PqAnchor.anchorDigest(CHAIN_ID, parent.getNumber(), parent.getHash().getBytes(), sorted); + return header(number, parent.getHash(), extraData(digest, sorted)); + } + + private List sealsFor(final BlockHeader parent, final List indices) { + // One return, one mutability. An early return of an immutable empty list here is what + // errorprone's MixedMutabilityReturnType refuses, and it is refusing something real: the empty + // case is not special, it is the case where the loop runs zero times. + final Bytes32 message = + PqAnchor.commitMessage(CHAIN_ID, parent.getNumber(), parent.getHash().getBytes()); + final List seals = new ArrayList<>(); + for (final int index : indices) { + seals.add(new FalconSeal(index, LocalRegistry.sign(index, message))); + } + return seals; + } + + private ProtocolContext contextWith(final Collection

parentValidators) { + final ValidatorProvider validatorProvider = + mock(ValidatorProvider.class, withSettings().strictness(Strictness.LENIENT)); + when(validatorProvider.getValidatorsForBlock(any())).thenReturn(parentValidators); + final BftContext bftContext = + mock(BftContext.class, withSettings().strictness(Strictness.LENIENT)); + when(bftContext.getValidatorProvider()).thenReturn(validatorProvider); + when(bftContext.as(any())).thenReturn(bftContext); + return new ProtocolContext.Builder().withConsensusContext(bftContext).build(); + } + + /** + * A registry of the same shape as the Falcon one, with a signature that is a deterministic + * function of the index AND the message, so a seal replayed at another height fails exactly as a + * real verification would. It exists so that the positive controls are real acceptances rather + * than acceptances bought by a registry that agrees to everything. + */ + private static final class LocalRegistry implements PqSignerRegistry { + + private final Map binding = new HashMap<>(); + + private LocalRegistry() { + for (int i = 0; i < VALIDATORS.size(); i++) { + binding.put(i, VALIDATORS.get(i)); + } + } + + static Bytes sign(final int index, final Bytes message) { + final byte[] out = new byte[655]; + out[0] = (byte) index; + for (int i = 0; i < message.size() && i < 32; i++) { + out[1 + i] = message.get(i); + } + for (int i = 33; i < out.length; i++) { + out[i] = (byte) ((i * (index + 3) + 7) & 0xFF); + } + return Bytes.wrap(out); + } + + @Override + public Address addressForIndexAtHistoric(final long blockNumber, final int validatorIndex) { + return binding.get(validatorIndex); + } + + @Override + public boolean verifyAtHistoric( + final long blockNumber, + final int validatorIndex, + final Bytes message, + final Bytes signature) { + return signature != null && signature.equals(sign(validatorIndex, message)); + } + + @Override + public Address addressForIndexAtOwnHead(final long blockNumber, final int validatorIndex) { + return addressForIndexAtHistoric(blockNumber, validatorIndex); + } + + @Override + public boolean verifyAtOwnHead( + final long blockNumber, + final int validatorIndex, + final Bytes message, + final Bytes signature) { + return verifyAtHistoric(blockNumber, validatorIndex, message, signature); + } + + @Override + public String toString() { + return "LocalRegistry"; + } + } +} diff --git a/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorSealsRuleTest.java b/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorSealsRuleTest.java new file mode 100755 index 000000000..385abfb1f --- /dev/null +++ b/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorSealsRuleTest.java @@ -0,0 +1,691 @@ +/* + * Copyright contributors to Besu / AERE Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.qbft.headervalidationrules; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.CHAIN_ID; +import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.H; +import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.VALIDATORS; +import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.extraData; +import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.header; +import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.parentHeader; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.withSettings; + +import org.hyperledger.besu.consensus.common.bft.BftContext; +import org.hyperledger.besu.consensus.common.bft.FalconSeal; +import org.hyperledger.besu.consensus.common.bft.PqAnchor; +import org.hyperledger.besu.consensus.common.bft.PqAnchorConfig; +import org.hyperledger.besu.consensus.common.bft.PqSignerRegistry; +import org.hyperledger.besu.consensus.common.validator.ValidatorProvider; +import org.hyperledger.besu.datatypes.Address; +import org.hyperledger.besu.ethereum.ProtocolContext; +import org.hyperledger.besu.ethereum.core.BlockHeader; +import org.hyperledger.besu.ethereum.mainnet.BlockHeaderValidator; +import org.hyperledger.besu.ethereum.mainnet.HeaderValidationMode; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.NavigableMap; +import java.util.OptionalInt; +import java.util.TreeMap; + +import org.apache.tuweni.bytes.Bytes; +import org.apache.tuweni.bytes.Bytes32; +import org.junit.jupiter.api.Test; +import org.mockito.quality.Strictness; + +/** + * R2, the anchor seals rule. + * + *

The registry is injected rather than taken from the Falcon singleton, so these tests exercise + * the RULE: threshold, ordering, eligibility against the parent's validator set, message binding and + * per-seal verification. Whether BouncyCastle's Falcon implementation verifies correctly is + * FalconSealSupport's own surface, and is deliberately not re-tested here. + * + *

The most important test in this file is {@link + * #acceptsAValidCertificateOverASubsetThisNodeNeverHeard()}. It is the one that proves the rule does + * not do the thing that killed the previous design. + */ +public class PqAnchorSealsRuleTest { + + private static final long K_AT = H + 100L; + + private final PqAnchorConfig armed = + new PqAnchorConfig(CHAIN_ID, H, Map.of(H, 0, K_AT, 5), OptionalInt.empty(), false); + private final FakeRegistry registry = new FakeRegistry(); + private final PqAnchorSealsRule rule = new PqAnchorSealsRule(armed, registry); + + // --------------------------------------------------------------------------------------------- + // Inert below H. + // --------------------------------------------------------------------------------------------- + + @Test + public void belowHTheRuleIsInert() { + final BlockHeader parent = parentHeader(H - 2L); + final BlockHeader nonsense = + header( + H - 1L, + parent.getHash(), + extraData(Bytes32.random(), List.of(new FalconSeal(99, Bytes.of(1, 2, 3))))); + assertThat(rule.validate(nonsense, parent, contextWith(VALIDATORS))).isTrue(); + } + + @Test + public void anUnconfiguredAnchorIsInertAtEveryHeight() { + final PqAnchorSealsRule inert = + new PqAnchorSealsRule(PqAnchorConfig.never(CHAIN_ID), registry); + final BlockHeader parent = parentHeader(K_AT + 4L); + final BlockHeader nonsense = + header( + K_AT + 5L, + parent.getHash(), + extraData(Bytes32.random(), List.of(new FalconSeal(99, Bytes.of(1, 2, 3))))); + assertThat(inert.validate(nonsense, parent, contextWith(VALIDATORS))).isTrue(); + } + + // --------------------------------------------------------------------------------------------- + // Honest certificates. + // --------------------------------------------------------------------------------------------- + + @Test + public void acceptsAQuorumCertificateOverTheParent() { + final BlockHeader parent = parentHeader(K_AT + 9L); + final BlockHeader block = blockWithSigners(K_AT + 10L, parent, List.of(0, 1, 2, 3, 4)); + assertThat(rule.validate(block, parent, contextWith(VALIDATORS))).isTrue(); + } + + @Test + public void acceptsAValidCertificateOverASubsetThisNodeNeverHeard() { + // A5, the attack that killed Option V, and the reason V2 exists. Measured on 200 consecutive + // live headers read from two of our own nodes with identical block hashes: the seal SET differed + // on 11 of them. A rule that compared the carried certificate with the local view would reject + // roughly one header in eighteen with no attacker present. This rule never consults a local + // view, so every well formed subset of eligible signers is accepted on its own merits. + final BlockHeader parent = parentHeader(K_AT + 9L); + final ProtocolContext context = contextWith(VALIDATORS); + for (final List subset : + List.of( + List.of(0, 1, 2, 3, 4), + List.of(0, 1, 2, 3, 5), + List.of(2, 3, 4, 5, 6), + List.of(0, 2, 4, 5, 6))) { + final BlockHeader block = blockWithSigners(K_AT + 10L, parent, subset); + assertThat(rule.validate(block, parent, context)) + .withFailMessage("subset %s should be accepted on its own merits", subset) + .isTrue(); + } + } + + @Test + public void acceptsMoreThanTheThreshold() { + final BlockHeader parent = parentHeader(K_AT + 9L); + final BlockHeader block = blockWithSigners(K_AT + 10L, parent, List.of(0, 1, 2, 3, 4, 5, 6)); + assertThat(rule.validate(block, parent, contextWith(VALIDATORS))).isTrue(); + } + + @Test + public void acceptsAnEmptyCertificateWhileTheThresholdIsZero() { + final BlockHeader parent = parentHeader(H - 1L); + final BlockHeader block = blockWithSigners(H, parent, List.of()); + assertThat(rule.validate(block, parent, contextWith(VALIDATORS))).isTrue(); + } + + // --------------------------------------------------------------------------------------------- + // The four checks. + // --------------------------------------------------------------------------------------------- + + @Test + public void rejectsACertificateBelowTheThresholdInForce() { + final BlockHeader parent = parentHeader(K_AT + 9L); + final BlockHeader block = blockWithSigners(K_AT + 10L, parent, List.of(0, 1, 2, 3)); + assertThat(rule.validate(block, parent, contextWith(VALIDATORS))).isFalse(); + } + + @Test + public void rejectsAnEmptyCertificateOnceTheThresholdIsAboveZero() { + final BlockHeader parent = parentHeader(K_AT + 9L); + final BlockHeader block = blockWithSigners(K_AT + 10L, parent, List.of()); + assertThat(rule.validate(block, parent, contextWith(VALIDATORS))).isFalse(); + } + + @Test + public void rejectsIndicesThatAreNotStrictlyIncreasing() { + final BlockHeader parent = parentHeader(K_AT + 9L); + final List reordered = + new ArrayList<>(sealsFor(parent, List.of(0, 1, 2, 3, 4))); + Collections.reverse(reordered); + final BlockHeader block = + header(K_AT + 10L, parent.getHash(), extraData(Bytes32.random(), reordered)); + assertThat(rule.validate(block, parent, contextWith(VALIDATORS))).isFalse(); + } + + @Test + public void rejectsAnIndexTheRegistryDoesNotBindToAnAddress() { + final BlockHeader parent = parentHeader(K_AT + 9L); + final List seals = new ArrayList<>(sealsFor(parent, List.of(0, 1, 2, 3))); + final Bytes32 message = PqAnchor.commitMessage(CHAIN_ID, parent.getNumber(), parent.getHash().getBytes()); + seals.add(new FalconSeal(42, FakeRegistry.sign(42, message))); + final BlockHeader block = + header(K_AT + 10L, parent.getHash(), extraData(Bytes32.random(), seals)); + assertThat(rule.validate(block, parent, contextWith(VALIDATORS))).isFalse(); + } + + @Test + public void rejectsASignerThatWasNotAValidatorOfTheParent() { + // Removal safety: a validator dropped before the parent no longer counts, even though its key is + // still in the registry. + final BlockHeader parent = parentHeader(K_AT + 9L); + final BlockHeader block = blockWithSigners(K_AT + 10L, parent, List.of(0, 1, 2, 3, 6)); + final List

shrunk = VALIDATORS.subList(0, 6); + assertThat(rule.validate(block, parent, contextWith(shrunk))).isFalse(); + // and the same certificate over the full parent set is fine, so the refusal is about + // eligibility and nothing else. + assertThat(rule.validate(block, parent, contextWith(VALIDATORS))).isTrue(); + } + + @Test + public void rejectsASignatureThatDoesNotVerify() { + final BlockHeader parent = parentHeader(K_AT + 9L); + final List seals = new ArrayList<>(sealsFor(parent, List.of(0, 1, 2, 3))); + seals.add(new FalconSeal(4, Bytes.repeat((byte) 0x5a, 655))); + final BlockHeader block = + header(K_AT + 10L, parent.getHash(), extraData(Bytes32.random(), seals)); + assertThat(rule.validate(block, parent, contextWith(VALIDATORS))).isFalse(); + } + + @Test + public void rejectsSealsReplayedFromAnotherHeight() { + // A6, first half: M binds the chain id, the block number and the block hash, so a certificate + // that is genuine over a different parent is not material here. + final BlockHeader parent = parentHeader(K_AT + 9L); + final BlockHeader otherParent = parentHeader(K_AT + 8L); + final List foreign = sealsFor(otherParent, List.of(0, 1, 2, 3, 4)); + final BlockHeader block = + header(K_AT + 10L, parent.getHash(), extraData(Bytes32.random(), foreign)); + assertThat(rule.validate(block, parent, contextWith(VALIDATORS))).isFalse(); + } + + @Test + public void rejectsSealsReplayedFromAnotherChain() { + // A6, second half: the 442807 proving chain runs the same binaries and can run the same keys. + final BlockHeader parent = parentHeader(K_AT + 9L); + final Bytes32 foreignMessage = + PqAnchor.commitMessage(442807L, parent.getNumber(), parent.getHash().getBytes()); + final List foreign = new ArrayList<>(); + for (final int index : List.of(0, 1, 2, 3, 4)) { + foreign.add(new FalconSeal(index, FakeRegistry.sign(index, foreignMessage))); + } + final BlockHeader block = + header(K_AT + 10L, parent.getHash(), extraData(Bytes32.random(), foreign)); + assertThat(rule.validate(block, parent, contextWith(VALIDATORS))).isFalse(); + } + + @Test + public void rejectsTwoIndicesThatResolveToOneValidator() { + // Strictly increasing indices stop a repeated INDEX. A malformed registry could still map two + // distinct indices onto one address, which would let one validator be counted twice. + final BlockHeader parent = parentHeader(K_AT + 9L); + registry.bind(7, VALIDATORS.get(0)); + final BlockHeader block = blockWithSigners(K_AT + 10L, parent, List.of(0, 1, 2, 3, 7)); + assertThat(rule.validate(block, parent, contextWith(VALIDATORS))).isFalse(); + } + + // --------------------------------------------------------------------------------------------- + // D2, the adversarial review of 2026-08-02, reproduced against R2 itself. + // + // The dossier's T2, in its own words: "a header that passes both rules today is REJECTED the + // moment index 0's Falcon key is rotated. Nothing else changes: same header, same parent, same + // validator set." T3: "if the index is rebound to ANOTHER validator who is still in the current + // set, the rule sees nothing." + // + // These two tests could not have been written before 2026-08-06. PqSignerRegistry carried a + // height-less pair plus a default that forwarded the height-aware form to it, so every test + // double in the tree - including the D2 harness's own RuleProbe - silently threw the height away + // and answered from ONE key set. A fake with one key set cannot express a rotation, so it cannot + // fail on one. The compiler now refuses that fake. + // --------------------------------------------------------------------------------------------- + + @Test + public void d2t2AHeaderBelowARotationIsStillVerifiedUnderTheKEYSETITWASSEALEDUNDER() { + // The T2 measurement, stated as a property rather than a scenario: an honest certificate over + // parent P must still verify after the chain rotates index 0's key at a LATER height. The seals + // are produced under generation 0, which is what was in force at P. + final long rotationHeight = K_AT + 5_000L; + final BlockHeader parent = parentHeader(K_AT + 9L); + final BlockHeader block = blockWithSigners(K_AT + 10L, parent, List.of(0, 1, 2, 3, 4)); + final ProtocolContext context = contextWith(VALIDATORS); + + assertThat(rule.validate(block, parent, context)) + .describedAs("baseline: the certificate is honest before any rotation") + .isTrue(); + + registry.rotateKeyFrom(rotationHeight, 0, 1); + + assertThat(rule.validate(block, parent, context)) + .describedAs( + "D2/T2: the SAME header, the SAME parent and the SAME validator set, after a key " + + "rotation at a height ABOVE it. R2 must resolve keys at the parent's height. If " + + "this is false the chain is unjoinable for every node that syncs after a rotation") + .isTrue(); + } + + @Test + public void d2t2ASealFromTheOLDKeyIsRefusedABOVETheRotation() { + // The other side of the same coin, and the reason the test above is not satisfied by a rule + // that ignores rotations entirely. Above the rotation height the old key must NOT verify - + // otherwise "rotation" would mean nothing and a revoked key would keep signing forever. + final long rotationHeight = K_AT + 5_000L; + registry.rotateKeyFrom(rotationHeight, 0, 1); + + final BlockHeader parent = parentHeader(rotationHeight + 9L); + final Bytes32 message = + PqAnchor.commitMessage(CHAIN_ID, parent.getNumber(), parent.getHash().getBytes()); + final List seals = new ArrayList<>(); + // index 0 signs with the RETIRED generation; the other four are honest. + seals.add(new FalconSeal(0, FakeRegistry.sign(0, 0, message))); + for (final int index : List.of(1, 2, 3, 4)) { + seals.add(new FalconSeal(index, FakeRegistry.sign(index, message))); + } + final BlockHeader block = + header(rotationHeight + 10L, parent.getHash(), extraData(Bytes32.random(), seals)); + + assertThat(rule.validate(block, parent, contextWith(VALIDATORS))) + .describedAs("a seal from the key the chain retired must not count above the rotation") + .isFalse(); + } + + @Test + public void d2t3ARebindingOfAnIndexToAnotherCURRENTValidatorIsNotSeenByR2() { + // T3, and this test is here to keep an OPEN finding open rather than to prove a repair. + // + // Measured, not argued: PqAnchorSealsRule credits a seal to whatever address the registry hands + // back for its index, checks that address against the parent's validator set, and never asks + // which KEY signed. So rebinding index 0 to validator 5 - who is in the set - is invisible: the + // seal was produced by index 0's key and is credited to validator 5. + // + // The height-indexed registry NARROWS this: a rebinding changes the registry's canonical hash + // (PqRegistryHash.java:274-277 puts the address in the pre-image), so it cannot happen without + // a new scheduled epoch that every node must be given. It does not CLOSE it: whoever writes + // that epoch can write the rebinding into it and the whole fleet will accept it. Closing it + // needs eligibility DERIVED from the Falcon key rather than bound to it by configuration. + final long rebindHeight = K_AT + 5_000L; + final BlockHeader parent = parentHeader(rebindHeight + 9L); + // Validator 5 does not sign; index 0's key does. After the rebinding the seal is credited to 5. + registry.rebindFrom(rebindHeight, 0, VALIDATORS.get(5)); + + final Bytes32 message = + PqAnchor.commitMessage(CHAIN_ID, parent.getNumber(), parent.getHash().getBytes()); + final List seals = new ArrayList<>(); + for (final int index : List.of(0, 1, 2, 3, 4)) { + seals.add(new FalconSeal(index, FakeRegistry.sign(index, message))); + } + final BlockHeader block = + header(rebindHeight + 10L, parent.getHash(), extraData(Bytes32.random(), seals)); + + assertThat(rule.validate(block, parent, contextWith(VALIDATORS))) + .describedAs( + "D2/T3 IS STILL OPEN: R2 accepts a certificate in which index 0's key is credited to " + + "validator 5. When this assertion has to be flipped to isFalse(), T3 has been " + + "closed and this comment is the record of when it was not") + .isTrue(); + } + + // --------------------------------------------------------------------------------------------- + // Fail-closed on everything else. + // --------------------------------------------------------------------------------------------- + + @Test + public void rejectsWhenTheParentIsMissingOrIsNotTheNamedParent() { + final BlockHeader parent = parentHeader(K_AT + 9L); + final BlockHeader block = blockWithSigners(K_AT + 10L, parent, List.of(0, 1, 2, 3, 4)); + final ProtocolContext context = contextWith(VALIDATORS); + assertThat(rule.validate(block, null, context)).isFalse(); + assertThat(rule.validate(block, parentHeader(K_AT + 8L), context)).isFalse(); + } + + @Test + public void rejectsWhenTheParentValidatorSetCannotBeResolved() { + final BlockHeader parent = parentHeader(K_AT + 9L); + final BlockHeader block = blockWithSigners(K_AT + 10L, parent, List.of(0, 1, 2, 3, 4)); + assertThat(rule.validate(block, parent, contextWith(Collections.emptyList()))).isFalse(); + assertThat(rule.validate(block, parent, contextWith(null))).isFalse(); + } + + @Test + public void rejectsUndecodableExtraData() { + final BlockHeader parent = parentHeader(K_AT + 9L); + final BlockHeader broken = + header(K_AT + 10L, parent.getHash(), Bytes.fromHexString("0xdeadbeef")); + assertThat(rule.validate(broken, parent, contextWith(VALIDATORS))).isFalse(); + } + + // --------------------------------------------------------------------------------------------- + // Where it runs. + // --------------------------------------------------------------------------------------------- + + @Test + public void includeInLightValidationIsFalseAndTheRuleIsSkippedThere() { + assertThat(rule.includeInLightValidation()).isFalse(); + final BlockHeader parent = parentHeader(K_AT + 9L); + final BlockHeader tooFew = blockWithSigners(K_AT + 10L, parent, List.of(0, 1)); + final ProtocolContext context = contextWith(VALIDATORS); + final BlockHeaderValidator validator = + new BlockHeaderValidator.Builder().addRule(rule).build(); + // Skipped in every light mode: this is the expensive half, and R1 already carries the binding + // into light validation. + assertThat(validator.validateHeader(tooFew, parent, context, HeaderValidationMode.LIGHT)) + .isTrue(); + assertThat( + validator.validateHeader( + tooFew, parent, context, HeaderValidationMode.LIGHT_SKIP_DETACHED)) + .isTrue(); + // And decisive in full validation. + assertThat(validator.validateHeader(tooFew, parent, context, HeaderValidationMode.FULL)) + .isFalse(); + } + + // --------------------------------------------------------------------------------------------- + // Helpers. + // --------------------------------------------------------------------------------------------- + + private List sealsFor(final BlockHeader parent, final List indices) { + final Bytes32 message = PqAnchor.commitMessage(CHAIN_ID, parent.getNumber(), parent.getHash().getBytes()); + final List seals = new ArrayList<>(); + for (final int index : indices) { + seals.add(new FalconSeal(index, FakeRegistry.sign(index, message))); + } + return PqAnchor.sortedByIndex(seals); + } + + /** + * A header carrying an honest certificate from the given signers. vanityData is set to the correct + * anchor digest so that a header built here would also satisfy R1; R2 does not read it. + */ + private BlockHeader blockWithSigners( + final long number, final BlockHeader parent, final List indices) { + final List seals = sealsFor(parent, indices); + final Bytes32 digest = + PqAnchor.anchorDigest(CHAIN_ID, parent.getNumber(), parent.getHash().getBytes(), seals); + return header(number, parent.getHash(), extraData(digest, seals)); + } + + private ProtocolContext contextWith(final Collection
parentValidators) { + final ValidatorProvider validatorProvider = + mock(ValidatorProvider.class, withSettings().strictness(Strictness.LENIENT)); + when(validatorProvider.getValidatorsForBlock(any())).thenReturn(parentValidators); + final BftContext bftContext = + mock(BftContext.class, withSettings().strictness(Strictness.LENIENT)); + when(bftContext.getValidatorProvider()).thenReturn(validatorProvider); + when(bftContext.as(any())).thenReturn(bftContext); + return new ProtocolContext.Builder().withConsensusContext(bftContext).build(); + } + + /** + * A registry with the same shape as the Falcon one: index to address, plus a verifier. The fake + * signature is a deterministic function of the index AND the message, so replaying a genuine seal + * at another height or on another chain fails exactly as a real Falcon verification would. + */ + private static final class FakeRegistry implements PqSignerRegistry { + + /** Epoch start height to the index-to-address binding in force from there. */ + private final NavigableMap> epochs = new TreeMap<>(); + + /** + * Epoch start height to the KEY GENERATION in force from there. The fake signature is a + * function of the generation, so "rotate index 0's key" and "rebind index 0 to another + * validator" are two DIFFERENT edits here, exactly as they are on the wire. T2 is the first, + * T3 the second. + */ + private final NavigableMap generations = new TreeMap<>(); + + private FakeRegistry() { + final Map genesisEpoch = new HashMap<>(); + for (int i = 0; i < VALIDATORS.size(); i++) { + genesisEpoch.put(i, VALIDATORS.get(i)); + } + epochs.put(0L, genesisEpoch); + generations.put(0L, 0); + } + + void bind(final int index, final Address address) { + epochs.get(epochs.firstKey()).put(index, address); + } + + /** + * D2/T2. From {@code from}, index {@code index} answers to a NEW key generation. Everything + * else - the address binding, the validator set, the parent - is untouched, which is the whole + * point of the T2 measurement: a header that verified yesterday must still verify today. + */ + void rotateKeyFrom(final long from, final int index, final int generation) { + final Map carried = new HashMap<>(epochs.floorEntry(from).getValue()); + epochs.put(from, carried); + generations.put(from, generation); + rotatedIndex = index; + } + + /** + * D2/T3. From {@code from}, index {@code index} answers to a DIFFERENT validator address, keys + * untouched. The seal is then credited to a validator that did not sign it. + */ + void rebindFrom(final long from, final int index, final Address address) { + final Map carried = new HashMap<>(epochs.floorEntry(from).getValue()); + carried.put(index, address); + epochs.put(from, carried); + generations.put(from, generations.floorEntry(from).getValue()); + } + + private int rotatedIndex = -1; + + static Bytes sign(final int index, final Bytes message) { + return sign(index, 0, message); + } + + static Bytes sign(final int index, final int generation, final Bytes message) { + final byte[] out = new byte[655]; + out[0] = (byte) index; + for (int i = 0; i < message.size() && i < 32; i++) { + out[1 + i] = message.get(i); + } + for (int i = 33; i < out.length; i++) { + out[i] = (byte) ((i * (index + 3) + 7 + generation * 31) & 0xFF); + } + return Bytes.wrap(out); + } + + private int generationAt(final long blockNumber, final int validatorIndex) { + final Integer g = generations.floorEntry(blockNumber).getValue(); + // Only the rotated index changes generation; every other index keeps generation 0. + return (rotatedIndex < 0 || validatorIndex == rotatedIndex) ? g : 0; + } + + // D2 HARDENING (a), 2026-08-06: the height-less pair is gone from PqSignerRegistry, so this + // double can no longer inherit a default that throws the height away. That default is why the + // original D2 harness (RuleProbe.java:115-131) returned the same verdict on repaired and + // unrepaired code; a fake that cannot express a rotation cannot prove one is handled. + @Override + public Address addressForIndexAtOwnHead(final long blockNumber, final int validatorIndex) { + return addressForIndexAtHistoric(blockNumber, validatorIndex); + } + + @Override + public boolean verifyAtOwnHead( + final long blockNumber, + final int validatorIndex, + final Bytes message, + final Bytes signature) { + return verifyAtHistoric(blockNumber, validatorIndex, message, signature); + } + + @Override + public Address addressForIndexAtHistoric(final long blockNumber, final int validatorIndex) { + return epochs.floorEntry(blockNumber).getValue().get(validatorIndex); + } + + @Override + public boolean verifyAtHistoric( + final long blockNumber, + final int validatorIndex, + final Bytes message, + final Bytes signature) { + return signature != null + && signature.equals(sign(validatorIndex, generationAt(blockNumber, validatorIndex), message)); + } + + @Override + public String toString() { + return "FakeRegistry"; + } + } + + // --------------------------------------------------------------------------------------------- + // D2 HARDENING (b-v2), 2026-08-06. WHICH DOOR R2 GOES THROUGH, measured by behaviour, not by + // name. + // + // The shape chosen here admits its own weakness: the compiler forces you to CHOOSE between + // addressForIndexAtHistoric and addressForIndexAtOwnHead, but it does not force you to choose + // CORRECTLY. Both have the same type and both are total, so a future patch that moves this call + // onto the OWN-HEAD door reopens D2 without anything going red, because the OWN-HEAD door is + // precisely the one that always answers. + // + // The two assertions below are the lock. The registry they run against answers DIFFERENTLY on + // the two doors, which no real registry does; that is exactly why it can say which door was + // opened. The pair also carries its positive control built in: if R2 called NEITHER door, the + // first assertion would pass and the second would fail. + // --------------------------------------------------------------------------------------------- + + @Test + public void r2GoesThroughTheHISTORYDoorAndThroughNothingElse() { + final BlockHeader parent = parentHeader(K_AT + 9L); + final BlockHeader block = blockWithSigners(K_AT + 10L, parent, List.of(0, 1, 2, 3, 4)); + final ProtocolContext context = contextWith(VALIDATORS); + + assertThat(rule.validate(block, parent, context)) + .describedAs("baseline: the same header is accepted by the ordinary registry") + .isTrue(); + + final PqAnchorSealsRule peUsaIstorie = + new PqAnchorSealsRule(armed, new OneDoorRegistry(registry, false)); + assertThat(peUsaIstorie.validate(block, parent, context)) + .describedAs( + "the HISTORY door refuses everything and the OWN-HEAD door answers everything. R2 must " + + "REJECT. If this is true, R2 is reading the own-head door, which means it would " + + "answer a header it received from the registry in force at this node's head - " + + "and that is D2 verbatim") + .isFalse(); + + final PqAnchorSealsRule peUsaCap = + new PqAnchorSealsRule(armed, new OneDoorRegistry(registry, true)); + assertThat(peUsaCap.validate(block, parent, context)) + .describedAs( + "and the mirror image: the HISTORY door answers, the OWN-HEAD door refuses, and R2 " + + "ACCEPTS. Without this second half the first could pass on a rule that calls " + + "neither door at all") + .isTrue(); + } + + /** + * A registry that answers on exactly ONE of the two doors and refuses on the other. No real + * registry behaves like this; that is the point - it is an instrument for asking which door a + * caller opened, and it is the only kind of double that can catch a call site moved to the wrong + * one. + */ + private static final class OneDoorRegistry implements PqSignerRegistry { + private final PqSignerRegistry delegate; + private final boolean historyAnswers; + + private OneDoorRegistry(final PqSignerRegistry delegate, final boolean historyAnswers) { + this.delegate = delegate; + this.historyAnswers = historyAnswers; + } + + @Override + public Address addressForIndexAtHistoric(final long blockNumber, final int validatorIndex) { + return historyAnswers ? delegate.addressForIndexAtHistoric(blockNumber, validatorIndex) : null; + } + + @Override + public boolean verifyAtHistoric( + final long blockNumber, + final int validatorIndex, + final Bytes message, + final Bytes signature) { + return historyAnswers + && delegate.verifyAtHistoric(blockNumber, validatorIndex, message, signature); + } + + @Override + public Address addressForIndexAtOwnHead(final long blockNumber, final int validatorIndex) { + return historyAnswers ? null : delegate.addressForIndexAtOwnHead(blockNumber, validatorIndex); + } + + @Override + public boolean verifyAtOwnHead( + final long blockNumber, + final int validatorIndex, + final Bytes message, + final Bytes signature) { + return !historyAnswers + && delegate.verifyAtOwnHead(blockNumber, validatorIndex, message, signature); + } + + @Override + public String toString() { + return "OneDoorRegistry{historyAnswers=" + historyAnswers + "}"; + } + } + + @Test + public void rejectsAnInvalidSignatureInEVERYPositionOfTheCertificate() { + // Added 2026-08-02 after a planted defect proved this was missing. + // + // The three signature tests above are all satisfied by a rule that skips the verification of + // ONE chosen position: rejectsASignatureThatDoesNotVerify puts its bad seal LAST, and the two + // replay tests make EVERY seal bad, so any one of them still fails on some other seal. A rule + // mutated to skip exactly the first seal's verification left all 187 tests of consensus:qbft + // green. An off-by-one in this loop, a stray continue, or a short-circuit on the first element + // is exactly that shape of bug, and it would have shipped. + // + // This test walks the bad signature through every position, so no per-position skip survives. + final BlockHeader parent = parentHeader(K_AT + 9L); + final List signers = List.of(0, 1, 2, 3, 4); + for (int position = 0; position < signers.size(); position++) { + final List seals = new ArrayList<>(sealsFor(parent, signers)); + final FalconSeal good = seals.get(position); + seals.set( + position, new FalconSeal(good.getValidatorIndex(), Bytes.repeat((byte) 0x5a, 655))); + final BlockHeader block = + header(K_AT + 10L, parent.getHash(), extraData(Bytes32.random(), seals)); + assertThat(rule.validate(block, parent, contextWith(VALIDATORS))) + .describedAs("certificate with the only invalid signature at position %d", position) + .isFalse(); + } + } + + @Test + public void acceptsTheSameCertificateWhenEveryPositionIsHonest() { + // The negative control for the test above: without it, a rule that rejected EVERYTHING would + // pass the per-position walk and prove nothing at all. + final BlockHeader parent = parentHeader(K_AT + 9L); + final BlockHeader block = blockWithSigners(K_AT + 10L, parent, List.of(0, 1, 2, 3, 4)); + assertThat(rule.validate(block, parent, contextWith(VALIDATORS))).isTrue(); + } +} diff --git a/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorTestSupport.java b/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorTestSupport.java new file mode 100755 index 000000000..f7961bcd3 --- /dev/null +++ b/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorTestSupport.java @@ -0,0 +1,144 @@ +/* + * Copyright contributors to Besu / AERE Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.qbft.headervalidationrules; + +import org.hyperledger.besu.consensus.common.bft.BftExtraData; +import org.hyperledger.besu.consensus.common.bft.FalconSeal; +import org.hyperledger.besu.consensus.common.bft.PqAnchor; +import org.hyperledger.besu.consensus.qbft.QbftExtraDataCodec; +import org.hyperledger.besu.datatypes.Address; +import org.hyperledger.besu.datatypes.Hash; +import org.hyperledger.besu.ethereum.core.BlockHeader; +import org.hyperledger.besu.ethereum.core.BlockHeaderTestFixture; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import org.apache.tuweni.bytes.Bytes; +import org.apache.tuweni.bytes.Bytes32; + +/** Header construction shared by the two anchor rule test classes. */ +public final class PqAnchorTestSupport { + + /** The chain the rules under test are configured for. */ + public static final long CHAIN_ID = 2800L; + + /** The activation height H used throughout these tests. */ + public static final long H = 1_000L; + + /** The QBFT validator addresses used as the parent's validator set. */ + public static final List
VALIDATORS = + List.of( + Address.fromHexString("0x0000000000000000000000000000000000000a01"), + Address.fromHexString("0x0000000000000000000000000000000000000a02"), + Address.fromHexString("0x0000000000000000000000000000000000000a03"), + Address.fromHexString("0x0000000000000000000000000000000000000a04"), + Address.fromHexString("0x0000000000000000000000000000000000000a05"), + Address.fromHexString("0x0000000000000000000000000000000000000a06"), + Address.fromHexString("0x0000000000000000000000000000000000000a07")); + + private static final QbftExtraDataCodec CODEC = new QbftExtraDataCodec(); + + private PqAnchorTestSupport() {} + + /** + * A deterministic stand-in for a Falcon-512 signature. {@code variant} lets a test produce a + * DIFFERENT but equally well formed signature under the same index, which is what an "equally + * valid certificate, different bytes" substitution looks like on the wire. + * + * @param index the validator index the signature belongs to + * @param variant a discriminator so two signatures for one index differ + * @return 655 deterministic bytes, the measured mean Falcon-512 signature length + */ + public static Bytes signature(final int index, final int variant) { + final byte[] bytes = new byte[655]; + for (int i = 0; i < bytes.length; i++) { + bytes[i] = (byte) ((i * 31 + index * 7 + variant * 101) & 0xFF); + } + return Bytes.wrap(bytes); + } + + /** + * A seal for a validator index. + * + * @param index the validator index + * @return the seal + */ + public static FalconSeal seal(final int index) { + return new FalconSeal(index, signature(index, 0)); + } + + /** + * Build the extraData bytes of a header carrying a given certificate and a given vanity field. + * + * @param vanity the 32-byte vanity field, which from H is the anchor digest D + * @param certificate the carried certificate C, in the order it is to be written + * @return canonical extraData bytes + */ + public static Bytes extraData(final Bytes vanity, final Collection certificate) { + return CODEC.encode( + new BftExtraData( + vanity, Collections.emptyList(), Optional.empty(), 0, VALIDATORS, certificate)); + } + + /** + * Build a header at a height, over a parent hash, carrying a certificate whose anchor digest is + * written into vanityData. This is what an HONEST proposer produces. + * + * @param number the height of the header + * @param parentHash the parent hash the header names + * @param certificate the certificate over the parent + * @return the header + */ + public static BlockHeader honestHeader( + final long number, final Hash parentHash, final List certificate) { + final List sorted = PqAnchor.sortedByIndex(certificate); + final Bytes32 digest = PqAnchor.anchorDigest(CHAIN_ID, number - 1L, parentHash.getBytes(), sorted); + return header(number, parentHash, extraData(digest, sorted)); + } + + /** + * Build a header with explicit extraData bytes, so a test can tamper with the certificate while + * leaving vanityData as the honest proposer wrote it. + * + * @param number the height of the header + * @param parentHash the parent hash the header names + * @param extraData the exact extraData bytes + * @return the header + */ + public static BlockHeader header( + final long number, final Hash parentHash, final Bytes extraData) { + return new BlockHeaderTestFixture() + .number(number) + .parentHash(parentHash) + .extraData(extraData) + .buildHeader(); + } + + /** + * The parent header a test uses, whose hash is what the child names. + * + * @param number the parent's height + * @return the parent header + */ + public static BlockHeader parentHeader(final long number) { + return new BlockHeaderTestFixture() + .number(number) + .extraData(extraData(Bytes32.ZERO, Collections.emptyList())) + .buildHeader(); + } +} diff --git a/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorV2RulesTest.java b/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorV2RulesTest.java new file mode 100755 index 000000000..4c542fe87 --- /dev/null +++ b/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorV2RulesTest.java @@ -0,0 +1,361 @@ +/* + * Copyright contributors to Aere Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in + * compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is + * distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the License for the specific language governing permissions and limitations under the + * License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.qbft.headervalidationrules; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.CHAIN_ID; +import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.H; +import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.VALIDATORS; +import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.header; +import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.parentHeader; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.withSettings; + +import org.hyperledger.besu.consensus.common.bft.BftContext; +import org.hyperledger.besu.consensus.common.bft.BftExtraData; +import org.hyperledger.besu.consensus.common.bft.FalconSeal; +import org.hyperledger.besu.consensus.common.bft.HybridSealSupport; +import org.hyperledger.besu.consensus.common.bft.PqAnchor; +import org.hyperledger.besu.consensus.common.bft.PqAnchorConfig; +import org.hyperledger.besu.consensus.common.bft.PqAnchorV2; +import org.hyperledger.besu.consensus.common.bft.PqSignerRegistry; +import org.hyperledger.besu.consensus.common.bft.SchemeSeal; +import org.hyperledger.besu.consensus.common.bft.SealScheme; +import org.hyperledger.besu.consensus.common.bft.SealSchemes; +import org.hyperledger.besu.consensus.common.validator.ValidatorProvider; +import org.hyperledger.besu.consensus.qbft.QbftExtraDataCodec; +import org.hyperledger.besu.crypto.SecureRandomProvider; +import org.hyperledger.besu.datatypes.Address; +import org.hyperledger.besu.ethereum.ProtocolContext; +import org.hyperledger.besu.ethereum.core.BlockHeader; +import org.hyperledger.besu.ethereum.rlp.RLPException; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.OptionalInt; + +import org.apache.tuweni.bytes.Bytes; +import org.apache.tuweni.bytes.Bytes32; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.quality.Strictness; + +/** + * AERE ANCHOR V2 (2026-09-03): the two header rules judge the SCHEME-TAGGED certificate, and the codec + * carries it in the sixth extraData element beside the v1 form. + * + *

Falcon seals come from a fake registry (a seal is valid iff it is exactly what the fake signs for + * that index over M(parent)); SLH-DSA seals are REAL, from probe keys generated here and published in + * a hybrid-1 registry loaded through the real system-configuration path. + * + *

Proven, each with its pair: R1 accepts the honest v2 header and refuses a stripped certificate + * (digest mismatch), a v2 certificate below the v2 height, and a v1 certificate at the v2 height; R2 + * accepts the honest v2 header and refuses an SLH-DSA seal from an index without a Falcon seal, a + * certificate short of one scheme, a tampered SLH-DSA signature, and a scheme the schedule does not + * name; the codec round-trips both forms and refuses a non-canonical v2 certificate. + */ +public class PqAnchorV2RulesTest { + + private static final long V2 = H + 100L; + private static final int K = 3; + private static final String SLH = "slh-dsa-sha2-128s"; + private static final QbftExtraDataCodec CODEC = new QbftExtraDataCodec(); + + @TempDir Path tmp; + + private final SecureRandom random = SecureRandomProvider.createSecureRandom(); + private final List slhKeys = new ArrayList<>(); + private final FakeFalconRegistry falcon = new FakeFalconRegistry(); + + private PqAnchorConfig armedV2; + private PqAnchorDigestRule r1; + private PqAnchorSealsRule r2; + + @BeforeEach + void setUp() throws Exception { + for (int i = 0; i < VALIDATORS.size(); i++) { + slhKeys.add(SealSchemes.SLH_DSA_128S.generate(random)); + } + armHybrid("0:falcon-512+" + SLH); + armedV2 = new PqAnchorConfig( + CHAIN_ID, H, Map.of(H, 0, V2, K), OptionalInt.empty(), false, OptionalInt.empty(), + OptionalInt.empty(), V2); + r1 = new PqAnchorDigestRule(armedV2); + r2 = new PqAnchorSealsRule(armedV2, falcon); + } + + @AfterEach + void tearDown() { + System.clearProperty(HybridSealSupport.PROPERTY_SCHEDULE); + System.clearProperty(HybridSealSupport.PROPERTY_REGISTRY); + HybridSealSupport.resetForTesting(); + } + + private void armHybrid(final String schedule) throws Exception { + final StringBuilder p = new StringBuilder(); + p.append("formatVersion=hybrid-1\nchainId=").append(CHAIN_ID).append("\ncount=") + .append(VALIDATORS.size()).append('\n'); + for (int i = 0; i < VALIDATORS.size(); i++) { + p.append(i).append(".addr=").append(VALIDATORS.get(i).toHexString()).append('\n'); + p.append(i).append(".key.").append(SLH).append('=') + .append(Bytes.wrap(slhKeys.get(i).publicRegistryForm()).toHexString()).append('\n'); + } + final Path reg = tmp.resolve("hybrid-" + schedule.replaceAll("[^a-z0-9]", "-") + ".properties"); + Files.writeString(reg, p.toString()); + System.setProperty(HybridSealSupport.PROPERTY_SCHEDULE, schedule); + System.setProperty(HybridSealSupport.PROPERTY_REGISTRY, reg.toAbsolutePath().toString()); + HybridSealSupport.resetForTesting(); + assertThat(HybridSealSupport.instance().registry()).isPresent(); + } + + // ------------------------------------------------------------------------------ certificates + + /** Falcon seals for indices 0..falconCount-1 and SLH-DSA seals for 0..slhCount-1, canonical. */ + private List certificate(final BlockHeader parent, final int falconCount, final int slhCount) { + final Bytes32 m = PqAnchor.commitMessage(CHAIN_ID, parent.getNumber(), parent.getHash().getBytes()); + final List out = new ArrayList<>(); + for (int i = 0; i < falconCount; i++) { + out.add(new SchemeSeal(SealSchemes.FALCON_512.wireId(), i, FakeFalconRegistry.sign(i, m))); + } + for (int i = 0; i < slhCount; i++) { + out.add(new SchemeSeal(SealSchemes.SLH_DSA_128S.wireId(), i, + Bytes.wrap(SealSchemes.SLH_DSA_128S.sign(slhKeys.get(i).privateKey(), m.toArray()).orElseThrow()))); + } + out.sort(PqAnchorV2.CANONICAL); + return out; + } + + private static Bytes extraDataV2(final Bytes vanity, final List tagged) { + return CODEC.encode(new BftExtraData( + vanity, Collections.emptyList(), Optional.empty(), 0, VALIDATORS, Collections.emptyList(), tagged)); + } + + private static BlockHeader honestV2(final long number, final BlockHeader parent, final List tagged) { + final Bytes32 d = PqAnchorV2.anchorDigestV2(CHAIN_ID, number - 1L, parent.getHash().getBytes(), tagged); + return header(number, parent.getHash(), extraDataV2(d, tagged)); + } + + // --------------------------------------------------------------------------------------- R1 + + @Test + public void r1AcceptsTheHonestV2HeaderAndRefusesAStrippedCertificate() { + final BlockHeader parent = parentHeader(V2 + 4L); + final List tagged = certificate(parent, 5, 5); + final BlockHeader honest = honestV2(V2 + 5L, parent, tagged); + assertThat(r1.validate(honest, parent)).isTrue(); + // one SLH-DSA seal stripped under the ORIGINAL digest + final List stripped = new ArrayList<>(tagged); + stripped.removeIf(s -> s.getSchemeWireId() == SealSchemes.SLH_DSA_128S.wireId() && s.getValidatorIndex() == 4); + final BftExtraData decoded = CODEC.decodeRaw(honest.getExtraData()); + final BlockHeader strippedHeader = + header(V2 + 5L, parent.getHash(), extraDataV2(decoded.getVanityData(), stripped)); + assertThat(r1.validate(strippedHeader, parent)).isFalse(); + } + + @Test + public void r1RefusesAV2CertificateBelowTheV2HeightAndAV1CertificateAtIt() { + // v2 below V2: the anchor height V2-1 (interval absent => every height from H is an anchor height) + final BlockHeader parentBelow = parentHeader(V2 - 2L); + final BlockHeader v2Early = honestV2(V2 - 1L, parentBelow, certificate(parentBelow, 5, 5)); + assertThat(r1.validate(v2Early, parentBelow)).isFalse(); + // v1 at V2: an honest v1 header (correct v1 digest) is still refused, the form is wrong + final BlockHeader parent = parentHeader(V2 + 4L); + final BlockHeader v1Late = PqAnchorTestSupport.honestHeader(V2 + 5L, parent.getHash(), + List.of(PqAnchorTestSupport.seal(0), PqAnchorTestSupport.seal(1), PqAnchorTestSupport.seal(2))); + assertThat(r1.validate(v1Late, parent)).isFalse(); + // control: the same v1 header is accepted by a rule whose V2 lies in the future + final PqAnchorConfig v1World = new PqAnchorConfig( + CHAIN_ID, H, Map.of(H, 0, V2, K), OptionalInt.empty(), false, OptionalInt.empty(), + OptionalInt.empty(), V2 + 1_000L); + assertThat(new PqAnchorDigestRule(v1World).validate(v1Late, parent)).isTrue(); + } + + // --------------------------------------------------------------------------------------- R2 + + @Test + public void r2AcceptsTheHonestV2HeaderWithKSealsPerScheme() { + final BlockHeader parent = parentHeader(V2 + 4L); + assertThat(r2.validate(honestV2(V2 + 5L, parent, certificate(parent, K, K)), parent, context())).isTrue(); + // more than K of each is fine too: K is a floor + assertThat(r2.validate(honestV2(V2 + 5L, parent, certificate(parent, 5, 5)), parent, context())).isTrue(); + } + + @Test + public void r2RefusesAnSlhDsaSealFromAnIndexWithoutAFalconSeal() { + final BlockHeader parent = parentHeader(V2 + 4L); + final List tagged = certificate(parent, K, K + 1); // SLH-DSA from index K has no Falcon + assertThat(r2.validate(honestV2(V2 + 5L, parent, tagged), parent, context())).isFalse(); + } + + @Test + public void r2RefusesACertificateShortOfOneScheme() { + final BlockHeader parent = parentHeader(V2 + 4L); + assertThat(r2.validate(honestV2(V2 + 5L, parent, certificate(parent, 5, K - 1)), parent, context())) + .describedAs("K Falcon seals and only K-1 SLH-DSA: the schedule demands K of EVERY scheme") + .isFalse(); + assertThat(r2.validate(honestV2(V2 + 5L, parent, certificate(parent, K - 1, K - 1)), parent, context())) + .describedAs("short of Falcon as well") + .isFalse(); + } + + @Test + public void r2RefusesATamperedSlhDsaSignature() { + final BlockHeader parent = parentHeader(V2 + 4L); + final List tagged = new ArrayList<>(certificate(parent, K, K)); + for (int i = 0; i < tagged.size(); i++) { + final SchemeSeal s = tagged.get(i); + if (s.getSchemeWireId() == SealSchemes.SLH_DSA_128S.wireId() && s.getValidatorIndex() == 1) { + final byte[] sig = s.getSignature().toArray(); + sig[sig.length / 2] ^= 0x01; + tagged.set(i, new SchemeSeal(s.getSchemeWireId(), s.getValidatorIndex(), Bytes.wrap(sig))); + } + } + assertThat(r2.validate(honestV2(V2 + 5L, parent, tagged), parent, context())).isFalse(); + } + + @Test + public void r2RefusesASchemeTheScheduleDoesNotName() throws Exception { + armHybrid("0:falcon-512"); + final BlockHeader parent = parentHeader(V2 + 4L); + assertThat(r2.validate(honestV2(V2 + 5L, parent, certificate(parent, K, K)), parent, context())) + .describedAs("SLH-DSA seals carried while the schedule names Falcon only") + .isFalse(); + assertThat(r2.validate(honestV2(V2 + 5L, parent, certificate(parent, K, 0)), parent, context())) + .describedAs("control: a Falcon-only v2 certificate under a Falcon-only schedule") + .isTrue(); + } + + // ------------------------------------------------------------------------------------ codec + + @Test + public void theCodecRoundTripsBothFormsAndRefusesANonCanonicalV2Certificate() { + final BlockHeader parent = parentHeader(V2 + 4L); + final List tagged = certificate(parent, 2, 2); + final Bytes v2 = extraDataV2(Bytes32.ZERO, tagged); + final BftExtraData back = CODEC.decodeRaw(v2); + assertThat(back.getHybridSeals()).isEqualTo(tagged); + assertThat(back.getFalconSeals()).isEmpty(); + // the v1 form still round-trips through the same slot + final List v1 = List.of(PqAnchorTestSupport.seal(0), PqAnchorTestSupport.seal(1)); + final BftExtraData backV1 = CODEC.decodeRaw(PqAnchorTestSupport.extraData(Bytes32.ZERO, v1)); + assertThat(backV1.getFalconSeals()).containsExactlyElementsOf(v1); + assertThat(backV1.getHybridSeals()).isEmpty(); + // a v2 certificate written out of canonical order is refused at decode, not silently reordered + final List unsorted = new ArrayList<>(tagged); + Collections.reverse(unsorted); + // the holder accepts any list; it is the ENCODER that refuses, so no header can carry it + assertThatThrownBy(() -> CODEC.encode(new BftExtraData( + Bytes32.ZERO, Collections.emptyList(), Optional.empty(), 0, VALIDATORS, + Collections.emptyList(), unsorted))) + .isInstanceOf(RuntimeException.class); + // hand-built non-canonical bytes: the same seals, encoded by hand out of order, do not decode + final Bytes canonical = PqAnchorV2.encode(tagged); + final BftExtraData holder = new BftExtraData( + Bytes32.ZERO, Collections.emptyList(), Optional.empty(), 0, VALIDATORS, Collections.emptyList(), tagged); + final Bytes good = CODEC.encode(holder); + assertThat(good.toHexString()).contains(canonical.toUnprefixedHexString()); + final Bytes swapped = Bytes.fromHexString(good.toHexString().replace( + canonical.toUnprefixedHexString(), PqAnchorV2.encode(List.of(tagged.get(0))).toUnprefixedHexString())); + assertThatThrownBy(() -> CODEC.decodeRaw(swapped)).isInstanceOf(RLPException.class); + } + + // ------------------------------------------------------------------- D-328: the round substitution + + @Test + public void replacingTheRoundInABlockKeepsTheV2Certificate() { + // 2026-09-03, testnet 28001: every sealed v2 header lost its certificate at replaceRoundInBlock, + // because that copy went through the v1 constructor; R1 then refused block 168032 for 3 hours. + final BlockHeader parent = parentHeader(V2 + 4L); + final List tagged = certificate(parent, K, K); + final BlockHeader honest = honestV2(V2 + 5L, parent, tagged); + final org.hyperledger.besu.ethereum.core.Block block = + new org.hyperledger.besu.ethereum.core.Block(honest, org.hyperledger.besu.ethereum.core.BlockBody.empty()); + final org.hyperledger.besu.ethereum.core.Block substituted = + new org.hyperledger.besu.consensus.common.bft.BftBlockInterface(CODEC) + .replaceRoundInBlock(block, 7, org.hyperledger.besu.consensus.common.bft.BftBlockHeaderFunctions.forCommittedSeal(CODEC)); + final BftExtraData after = CODEC.decodeRaw(substituted.getHeader().getExtraData()); + assertThat(after.getRound()).isEqualTo(7); + assertThat(after.getHybridSeals()).describedAs("the v2 certificate survives the round substitution").isEqualTo(tagged); + assertThat(after.getFalconSeals()).isEmpty(); + assertThat(r1.validate(substituted.getHeader(), parent)).describedAs("R1 still binds it").isTrue(); + } + + // ---------------------------------------------------------------------------------- helpers + + private static ProtocolContext context() { + return contextWith(VALIDATORS); + } + + private static ProtocolContext contextWith(final Collection

vs) { + final ValidatorProvider validatorProvider = + mock(ValidatorProvider.class, withSettings().strictness(Strictness.LENIENT)); + when(validatorProvider.getValidatorsForBlock(any())).thenReturn(vs); + when(validatorProvider.getValidatorsAfterBlock(any())).thenReturn(vs); + final BftContext bftContext = mock(BftContext.class, withSettings().strictness(Strictness.LENIENT)); + when(bftContext.getValidatorProvider()).thenReturn(validatorProvider); + when(bftContext.as(any())).thenReturn(bftContext); + return new ProtocolContext.Builder().withConsensusContext(bftContext).build(); + } + + /** Index i is VALIDATORS.get(i); a seal is valid iff it is exactly what {@link #sign} produces. */ + private static final class FakeFalconRegistry implements PqSignerRegistry { + static Bytes sign(final int index, final Bytes32 message) { + final byte[] out = new byte[655]; + out[0] = (byte) index; + for (int i = 0; i < 32; i++) { + out[1 + i] = message.get(i); + } + for (int i = 33; i < out.length; i++) { + out[i] = (byte) ((i * (index + 3) + 7) & 0xFF); + } + return Bytes.wrap(out); + } + + @Override + public Address addressForIndexAtHistoric(final long blockNumber, final int validatorIndex) { + return validatorIndex >= 0 && validatorIndex < VALIDATORS.size() ? VALIDATORS.get(validatorIndex) : null; + } + + @Override + public Address addressForIndexAtOwnHead(final long blockNumber, final int validatorIndex) { + return addressForIndexAtHistoric(blockNumber, validatorIndex); + } + + @Override + public boolean verifyAtHistoric( + final long blockNumber, final int validatorIndex, final Bytes message, final Bytes signature) { + return message.size() == 32 && sign(validatorIndex, Bytes32.wrap(message)).equals(signature); + } + + @Override + public boolean verifyAtOwnHead( + final long blockNumber, final int validatorIndex, final Bytes message, final Bytes signature) { + return verifyAtHistoric(blockNumber, validatorIndex, message, signature); + } + } +} diff --git a/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqArmedWithoutRegistryTest.java b/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqArmedWithoutRegistryTest.java new file mode 100755 index 000000000..ec110ed67 --- /dev/null +++ b/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqArmedWithoutRegistryTest.java @@ -0,0 +1,185 @@ +/* + * Copyright contributors to Besu / AERE Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.qbft.headervalidationrules; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.VALIDATORS; +import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.parentHeader; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.withSettings; + +import org.hyperledger.besu.consensus.common.bft.BftContext; +import org.hyperledger.besu.consensus.common.bft.FalconSealSupport; +import org.hyperledger.besu.consensus.common.bft.PqV2Fixture; +import org.hyperledger.besu.consensus.common.validator.ValidatorProvider; +import org.hyperledger.besu.datatypes.Address; +import org.hyperledger.besu.ethereum.ProtocolContext; +import org.hyperledger.besu.ethereum.core.BlockHeader; + +import java.lang.reflect.Field; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collection; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.quality.Strictness; + +/** + * D-079, THE RESIDUAL: the accident the configuration guard cannot refuse. + * + *

{@code FalconSealSupport.validateAnchorObservationHeightOrAbort} refuses to start a node whose + * blocking height is armed at or before the height at which its registry can become active. That + * closes the misconfiguration. It cannot close the ACCIDENT: an operator declares the observation + * height correctly, the anchor-deploy transaction does not land, and the blocking height arrives + * over an empty registry anyway. + * + *

{@link FalconSealValidationRule} answers that by staying LOG-ONLY, and that answer is right - + * blocking over an empty registry buys no safety and costs the chain. It is also exactly how the + * condition used to disappear: the node was configured to enforce a post-quantum quorum, enforced + * nothing, and said so in a warning that nothing reads and no command can exit on. + * + *

These three tests measure the mark it now leaves, in both directions. + */ +// The D-079 label is our internal finding id. It names a fact about this +// code, not anything outside it. +public class PqArmedWithoutRegistryTest { + + private static final int N = 9; + + private static final long OBSERVE = 5_000L; + + private static final long ATTACH = 6_000L; + + private static final long FORK = 7_000L; + + private static final String ANCHOR_ADDRESS = "0x0000000000000000000000000000000000000fa1"; + + /** AERE D-146: the chain this fixture's registry is BOUND to. Inside every proof, so stated. */ + private static final long CHAIN_ID = 2_800L; + + @TempDir private Path tmp; + + @BeforeEach + public void setUp() throws Exception { + // AERE D-146 (2026-08-06): v2, proof-bound, bound at FORK. This manifest used to spell its + // addresses 0xC00+i; no secp256k1 key produces those, so once AERE-PQC-REG-ARM-02 was wired + // this armed fixture could not start at all. PqV2Fixture lives in consensus:common's test + // source set and reaches here through the testArtifacts dependency this module already had. + final StringBuilder m = new StringBuilder("{"); + m.append(PqV2Fixture.manifestHeader(N, CHAIN_ID, FORK)); + for (int i = 0; i < N; i++) { + m.append(',').append(PqV2Fixture.manifestEntry(i, N, CHAIN_ID, FORK)); + } + m.append("}"); + final Path manifest = tmp.resolve("falcon-late-manifest.json"); + Files.writeString(manifest, m.toString()); + + System.setProperty("aere.falcon.manifest", manifest.toAbsolutePath().toString()); + System.setProperty("aere.falcon.anchor.address", ANCHOR_ADDRESS); + System.setProperty("aere.falcon.anchor.block", Long.toString(OBSERVE)); + System.setProperty("aere.falcon.attachBlock", Long.toString(ATTACH)); + System.setProperty("aere.falcon.forkBlock", Long.toString(FORK)); + System.setProperty("aere.falcon.validatorCount", Integer.toString(N)); + forgetFalconSingleton(); + } + + @AfterEach + public void tearDown() throws Exception { + for (final String p : + new String[] { + "aere.falcon.manifest", + "aere.falcon.anchor.address", + "aere.falcon.anchor.block", + "aere.falcon.attachBlock", + "aere.falcon.forkBlock", + "aere.falcon.validatorCount" + }) { + System.clearProperty(p); + } + forgetFalconSingleton(); + } + + @Test + public void belowTheBlockingHeightNothingIsRecorded() { + // The negative control. A counter that were set unconditionally would pass the test below and + // mean nothing at all. + final FalconSealValidationRule rule = new FalconSealValidationRule(Long.MAX_VALUE); + final BlockHeader parent = parentHeader(FORK - 2L); + final BlockHeader block = parentHeader(FORK - 1L); + + assertThat(rule.validate(block, parent, contextWith(VALIDATORS))).isTrue(); + assertThat(FalconSealSupport.instance().blockingArmedWithoutRegistrySince()) + .describedAs("below the blocking height there is nothing inert about being log-only") + .isEqualTo(-1L); + } + + @Test + public void atTheBlockingHeightWithNoActiveRegistryTheHeightIsRecorded() { + final FalconSealValidationRule rule = new FalconSealValidationRule(Long.MAX_VALUE); + final BlockHeader parent = parentHeader(FORK - 1L); + final BlockHeader block = parentHeader(FORK); + + assertThat(FalconSealSupport.instance().blockingArmedWithoutRegistrySince()).isEqualTo(-1L); + assertThat(rule.validate(block, parent, contextWith(VALIDATORS))) + .describedAs( + "the rule must still ACCEPT: blocking over an empty registry is a halt, not a safeguard") + .isTrue(); + assertThat(FalconSealSupport.instance().blockingArmedWithoutRegistrySince()) + .describedAs( + "the node is configured to enforce a Falcon quorum at %d and is enforcing nothing. That " + + "must be a value something can read, not a line in a file.", + FORK) + .isEqualTo(FORK); + assertThat(FalconSealSupport.instance().anchorObserveBlock()) + .describedAs("and the declared height it was measured against must be readable too") + .isEqualTo(OBSERVE); + } + + @Test + public void theRecordedHeightIsTheFirstOneAndDoesNotMoveWithTheChain() { + final FalconSealValidationRule rule = new FalconSealValidationRule(Long.MAX_VALUE); + rule.validate(parentHeader(FORK), parentHeader(FORK - 1L), contextWith(VALIDATORS)); + rule.validate(parentHeader(FORK + 40L), parentHeader(FORK + 39L), contextWith(VALIDATORS)); + + assertThat(FalconSealSupport.instance().blockingArmedWithoutRegistrySince()) + .describedAs( + "the value answers 'since when', so a later block must not overwrite it; if it tracked " + + "the head it would report a fresh problem forever and never a duration") + .isEqualTo(FORK); + } + + private static ProtocolContext contextWith(final Collection

validators) { + final ValidatorProvider validatorProvider = + mock(ValidatorProvider.class, withSettings().strictness(Strictness.LENIENT)); + when(validatorProvider.getValidatorsForBlock(any())).thenReturn(validators); + when(validatorProvider.getValidatorsAfterBlock(any())).thenReturn(validators); + final BftContext bftContext = + mock(BftContext.class, withSettings().strictness(Strictness.LENIENT)); + when(bftContext.getValidatorProvider()).thenReturn(validatorProvider); + when(bftContext.as(any())).thenReturn(bftContext); + return new ProtocolContext.Builder().withConsensusContext(bftContext).build(); + } + + private static void forgetFalconSingleton() throws Exception { + final Field f = FalconSealSupport.class.getDeclaredField("instance"); + f.setAccessible(true); + f.set(null, null); + } +} diff --git a/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqEmergencyShoutRuleTest.java b/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqEmergencyShoutRuleTest.java new file mode 100755 index 000000000..17d4dbc61 --- /dev/null +++ b/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqEmergencyShoutRuleTest.java @@ -0,0 +1,78 @@ +/* + * Copyright contributors to Besu / AERE Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.qbft.headervalidationrules; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.CHAIN_ID; +import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.H; +import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.parentHeader; + +import org.hyperledger.besu.consensus.common.bft.PqAnchorConfig; +import org.hyperledger.besu.ethereum.core.BlockHeader; + +import java.util.Map; +import java.util.OptionalInt; + +import org.junit.jupiter.api.Test; + +/** + * AERE OPTIUNI-URGENTA: the rule whose entire job is to be impossible to ignore. + * + *

The one property that must hold on every path is that it CANNOT REJECT. It is placed first in + * the QBFT header ruleset precisely because a rule that always returns true cannot change the + * verdict of a conjunction, and that claim has to be asserted rather than assumed, including for a + * header it has never seen before and for a null parent. + */ +class PqEmergencyShoutRuleTest { + + private PqEmergencyShoutRule rule(final boolean disabled, final OptionalInt ceiling) { + return new PqEmergencyShoutRule( + new PqAnchorConfig(CHAIN_ID, H, Map.of(H, 0, H + 100L, 5), ceiling, disabled)); + } + + @Test + void acceptsEveryHeaderWhenNothingIsOverridden() { + final PqEmergencyShoutRule r = rule(false, OptionalInt.empty()); + for (final long n : new long[] {0L, 1L, H - 1L, H, H + 100L, H + 1_000_000L}) { + final BlockHeader header = parentHeader(n); + assertThat(r.validate(header, parentHeader(Math.max(0L, n - 1L)))).isTrue(); + } + } + + @Test + void acceptsEveryHeaderWhenTheAnchorIsDisarmed() { + final PqEmergencyShoutRule r = rule(true, OptionalInt.empty()); + assertThat(r.validate(parentHeader(H + 5L), parentHeader(H + 4L))).isTrue(); + // Twice at the same height: the second call is the bounded one, and it must still accept. + assertThat(r.validate(parentHeader(H + 5L), parentHeader(H + 4L))).isTrue(); + } + + @Test + void acceptsEveryHeaderWhenTheThresholdIsCapped() { + final PqEmergencyShoutRule r = rule(false, OptionalInt.of(1)); + assertThat(r.validate(parentHeader(H + 150L), parentHeader(H + 149L))).isTrue(); + } + + @Test + void acceptsEvenWithoutAParent() { + final PqEmergencyShoutRule r = rule(true, OptionalInt.of(0)); + assertThat(r.validate(parentHeader(H + 150L), null)).isTrue(); + } + + @Test + void isPartOfLightValidationSoTheAnnouncementIsHeardOnEveryPath() { + assertThat(rule(true, OptionalInt.empty()).includeInLightValidation()).isTrue(); + } +} diff --git a/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqForkGateFeedTest.java b/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqForkGateFeedTest.java new file mode 100755 index 000000000..3b9813371 --- /dev/null +++ b/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqForkGateFeedTest.java @@ -0,0 +1,191 @@ +/* + * Copyright contributors to Besu / AERE Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.qbft.headervalidationrules; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.CHAIN_ID; +import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.H; +import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.VALIDATORS; +import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.parentHeader; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.withSettings; + +import org.hyperledger.besu.consensus.common.bft.BftContext; +import org.hyperledger.besu.consensus.common.bft.FalconSealSupport; +import org.hyperledger.besu.consensus.common.bft.PqAnchorConfig; +import org.hyperledger.besu.consensus.common.validator.ValidatorProvider; +import org.hyperledger.besu.datatypes.Address; +import org.hyperledger.besu.ethereum.ProtocolContext; +import org.hyperledger.besu.ethereum.core.BlockHeader; + +import java.lang.reflect.Field; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.OptionalInt; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.quality.Strictness; + +/** + * D-078, THE OTHER HALF: who feeds the seal-attachment gate above the anchor height. + * + *

The gate's registry-coverage report reads a validator set recorded by {@code + * FalconSealSupport.observeValidators}. Until 2026-08-02 the ONLY caller of that method was {@link + * FalconSealValidationRule}, and {@link PqAnchorConfig#legacyFalconRuleRetirementBlock()} stands + * that rule down at exactly the anchor height H. So from H upward nothing fed it: the recorded set + * was either frozen at a height below H, or - on any node whose process started above H - never + * recorded at all. The old gate answered "never recorded" by switching seal attachment off, which + * is one restart away from a chain that cannot propose. + * + *

This class measures the WIRING, in both directions, and it is the only thing that separates the + * repair from a claim about it: + * + *

    + *
  • the legacy rule really does stand down at H, so it really cannot be the feed above H; + *
  • {@link PqAnchorSealsRule}, which runs at every height from H, really does feed it. + *
+ */ +// The D-078 label is our internal finding id. It names a fact about this +// code, not anything outside it. +public class PqForkGateFeedTest { + + private static final PqAnchorConfig ARMED = + new PqAnchorConfig(CHAIN_ID, H, Map.of(H, 0), OptionalInt.empty(), false); + + @BeforeEach + public void resetSingleton() throws Exception { + forgetFalconSingleton(); + } + + @AfterEach + public void resetSingletonAfter() throws Exception { + forgetFalconSingleton(); + } + + @Test + public void theLegacyRuleStandsDownAtTheAnchorHeightSoItCannotBeTheFeed() { + assertThat(ARMED.legacyFalconRuleRetirementBlock()) + .describedAs("the legacy Falcon rule retires at exactly the anchor height") + .isEqualTo(H); + + final FalconSealValidationRule legacy = + new FalconSealValidationRule(ARMED.legacyFalconRuleRetirementBlock()); + final BlockHeader parent = parentHeader(H - 1L); + final BlockHeader atH = parentHeader(H); + + assertThat(legacy.validate(atH, parent, contextWith(VALIDATORS))) + .describedAs("retired, so it accepts without doing anything") + .isTrue(); + assertThat(FalconSealSupport.instance().observedValidatorsHeight()) + .describedAs( + "at and above the anchor height the legacy rule records NOTHING. That is correct for the " + + "rule and fatal for anything that depended on it as its only source.") + .isEqualTo(-1L); + } + + @Test + public void theAnchorSealsRuleFeedsTheGateAtEveryHeightFromH() { + final PqAnchorSealsRule rule = new PqAnchorSealsRule(ARMED, new NoRegistry()); + final BlockHeader parent = parentHeader(H + 40L); + final BlockHeader block = PqAnchorTestSupport.honestHeader(H + 41L, parent.getHash(), List.of()); + + assertThat(FalconSealSupport.instance().observedValidatorsHeight()).isEqualTo(-1L); + assertThat(rule.validate(block, parent, contextWith(VALIDATORS))) + .describedAs("K is 0 at this height, so an empty certificate is legitimate") + .isTrue(); + assertThat(FalconSealSupport.instance().observedValidatorsHeight()) + .describedAs( + "the rule that takes over at H must also take over feeding the coverage report, or the " + + "report is about a height the chain left behind") + .isEqualTo(parent.getNumber()); + } + + @Test + public void belowTheAnchorHeightTheSealsRuleRecordsNothing() { + // The negative control for the test above: a rule that recorded unconditionally would pass it + // while breaking the height gate that keeps the whole scheme inert below H. + final PqAnchorSealsRule rule = new PqAnchorSealsRule(ARMED, new NoRegistry()); + final BlockHeader parent = parentHeader(H - 3L); + final BlockHeader block = PqAnchorTestSupport.honestHeader(H - 2L, parent.getHash(), List.of()); + + assertThat(rule.validate(block, parent, contextWith(VALIDATORS))).isTrue(); + assertThat(FalconSealSupport.instance().observedValidatorsHeight()) + .describedAs("below H this rule does nothing at all, recording included") + .isEqualTo(-1L); + } + + private static ProtocolContext contextWith(final Collection
validators) { + final ValidatorProvider validatorProvider = + mock(ValidatorProvider.class, withSettings().strictness(Strictness.LENIENT)); + when(validatorProvider.getValidatorsForBlock(any())).thenReturn(validators); + when(validatorProvider.getValidatorsAfterBlock(any())).thenReturn(validators); + final BftContext bftContext = + mock(BftContext.class, withSettings().strictness(Strictness.LENIENT)); + when(bftContext.getValidatorProvider()).thenReturn(validatorProvider); + when(bftContext.as(any())).thenReturn(bftContext); + return new ProtocolContext.Builder().withConsensusContext(bftContext).build(); + } + + private static void forgetFalconSingleton() throws Exception { + final Field f = FalconSealSupport.class.getDeclaredField("instance"); + f.setAccessible(true); + f.set(null, null); + } + + /** A registry that binds nothing: this file measures the feed, never the verification. */ + private static final class NoRegistry + implements org.hyperledger.besu.consensus.common.bft.PqSignerRegistry { + + // D2 (2026-08-06): the height-less pair was deleted from PqSignerRegistry, so this double now + // has to answer "at which height" like everything else. It still binds nothing. + @Override + public Address addressForIndexAtOwnHead(final long blockNumber, final int validatorIndex) { + return addressForIndexAtHistoric(blockNumber, validatorIndex); + } + + @Override + public boolean verifyAtOwnHead( + final long blockNumber, + final int validatorIndex, + final org.apache.tuweni.bytes.Bytes message, + final org.apache.tuweni.bytes.Bytes signature) { + return verifyAtHistoric(blockNumber, validatorIndex, message, signature); + } + + @Override + public Address addressForIndexAtHistoric(final long blockNumber, final int validatorIndex) { + return null; + } + + @Override + public boolean verifyAtHistoric( + final long blockNumber, + final int validatorIndex, + final org.apache.tuweni.bytes.Bytes message, + final org.apache.tuweni.bytes.Bytes signature) { + return false; + } + + @Override + public String toString() { + return "NoRegistry"; + } + } +} diff --git a/ethereum/eth/src/main/java/org/hyperledger/besu/ethereum/eth/sync/DownloadHeadersStep.java b/ethereum/eth/src/main/java/org/hyperledger/besu/ethereum/eth/sync/DownloadHeadersStep.java index c8eb53639..76c435677 100644 --- a/ethereum/eth/src/main/java/org/hyperledger/besu/ethereum/eth/sync/DownloadHeadersStep.java +++ b/ethereum/eth/src/main/java/org/hyperledger/besu/ethereum/eth/sync/DownloadHeadersStep.java @@ -11,6 +11,12 @@ * specific language governing permissions and limitations under the License. * * SPDX-License-Identifier: Apache-2.0 + * + * Modifications Copyright contributors to the Aere Network. + * + * This file was modified by contributors to the Aere Network, as required by section 4(b) of the + * Apache License 2.0. The copyright header above is the upstream one and is left exactly as it was + * found, as section 4(c) requires. The change itself is in patches/0003-aere-pq-anchor.patch. */ package org.hyperledger.besu.ethereum.eth.sync; @@ -26,7 +32,10 @@ import org.hyperledger.besu.ethereum.eth.manager.peertask.task.GetHeadersFromPee import org.hyperledger.besu.ethereum.eth.sync.range.RangeHeaders; import org.hyperledger.besu.ethereum.eth.sync.range.SyncTargetRange; import org.hyperledger.besu.ethereum.eth.sync.tasks.DownloadHeaderSequenceTask; +import org.hyperledger.besu.ethereum.eth.sync.tasks.exceptions.InvalidBlockException; +import org.hyperledger.besu.ethereum.mainnet.HeaderValidationMode; import org.hyperledger.besu.ethereum.mainnet.ProtocolSchedule; +import org.hyperledger.besu.ethereum.mainnet.ProtocolSpec; import org.hyperledger.besu.plugin.services.MetricsSystem; import org.hyperledger.besu.util.FutureUtils; @@ -112,11 +121,103 @@ public class DownloadHeadersStep return CompletableFuture.failedFuture( new RuntimeException("Unable to download headers for range " + range)); } - return CompletableFuture.completedFuture(taskResult.result().get()); + final List downloaded = taskResult.result().get(); + // AERE SINCRONIZARE (2026-08-02): validate the OPEN-ENDED range too. + try { + validateOpenEndedRange(range, downloaded); + } catch (final RuntimeException e) { + return CompletableFuture.failedFuture(e); + } + return CompletableFuture.completedFuture(downloaded); }); } } + /** + * AERE SINCRONIZARE (2026-08-02). Apply the SAME validation policy to the open-ended range that + * the closed ranges already get. + * + *

The defect, read in this file and then measured on a running network. {@link + * #downloadHeaders} has two branches and the choice between them is not "syncing or not", it is + * {@link SyncTargetRange#hasEnd()}. A closed range goes to {@code + * DownloadHeaderSequenceTask.endingAtHeader}, which validates every header it accepts under + * {@code validationPolicy}. The open-ended range - the one at the tip, when the range source + * could not obtain a further range boundary from the peer - went to a bare {@code + * GetHeadersFromPeerTask} and was handed onward with nothing checked but the hash chaining that + * {@code RangeHeadersValidationStep} does at the join. With the default header request size of + * 200 that is the last 199 blocks of every catch-up, which is the whole of a short one. + * + *

The consequence for the certificate anchor was measured: an honest node with its data + * directory deleted imported headers whose certificate had been tampered with, in silence, + * because the only rule that binds the certificate to the block hash is detached and detached + * rules run in exactly the branch this range does not take. The attached copy of that rule now + * catches such a header at import in any case; this method makes it fail one pipeline stage + * earlier, before bodies are fetched for a range that is going to be thrown away, and restores + * the property that the tail of a sync is checked exactly like the rest of it. + * + *

Fail closed on every path: a range whose headers do not form a chain from {@code + * range.getStart()} is rejected here rather than trimmed or accepted. + * + * @param range the open-ended range these headers were requested for + * @param headers the headers as the peer returned them, ascending from {@code range.getStart()} + */ + private void validateOpenEndedRange( + final SyncTargetRange range, final List headers) { + if (headers.isEmpty()) { + return; + } + final BlockHeader start = range.getStart(); + BlockHeader parent; + int index; + if (headers.getFirst().equals(start)) { + // The usual shape: the peer echoes the header we asked to start from. + parent = start; + index = 1; + } else if (headers.getFirst().getParentHash().equals(start.getHash())) { + parent = start; + index = 0; + } else { + throw InvalidBlockException.fromInvalidBlock( + String.format( + "AERE-SYNC-TAIL-01: open-ended range starting at #%d (%s) came back beginning at #%d " + + "(%s), which neither is that header nor descends from it", + start.getNumber(), + start.getHash(), + headers.getFirst().getNumber(), + headers.getFirst().getHash()), + headers.getFirst()); + } + + final HeaderValidationMode mode = validationPolicy.getValidationModeForNextBlock(); + for (int i = index; i < headers.size(); i++) { + final BlockHeader header = headers.get(i); + if (!header.getParentHash().equals(parent.getHash()) + || header.getNumber() != parent.getNumber() + 1L) { + throw InvalidBlockException.fromInvalidBlock( + String.format( + "AERE-SYNC-TAIL-01: open-ended range does not form a chain at #%d (%s); previous " + + "header was #%d (%s)", + header.getNumber(), header.getHash(), parent.getNumber(), parent.getHash()), + header); + } + final ProtocolSpec protocolSpec = protocolSchedule.getByBlockHeader(header); + if (!protocolSpec + .getBlockHeaderValidator() + .validateHeader(header, parent, protocolContext, mode)) { + LOG.warn( + "AERE-SYNC-TAIL-01: header #{} ({}) of the OPEN-ENDED range failed {} validation. This " + + "is the branch that used to accept the last {} headers of every recovery without " + + "running a single header rule.", + header.getNumber(), + header.getHash(), + mode, + headerRequestSize - 1); + throw InvalidBlockException.fromInvalidBlock(header); + } + parent = header; + } + } + private RangeHeaders processHeaders( final SyncTargetRange checkpointRange, final List headers) { if (checkpointRange.hasEnd()) {