The round-change now carries an optional post-quantum seal, disarmed by default

The last hot-path QBFT message under the same pattern as prepare and proposal:
its own domain AERE-PQ-ROUNDCHANGE-1 over (chainId, height, targetRound, prepared
metadata - so a bare 'move on' seal cannot be replayed as 'move on and re-propose
THIS block'), a separate emission gate (aere.pq.roundChangePq.attachBlock), a
separate enforcement height (aere.pq.roundChangePq.forkBlock) self-wired into the
payload validator so it also judges round-changes inside proposal justifications,
and byte-for-byte upstream encoding when no seal is present. The seal is signed
once and the same object goes on the wire; Falcon signatures are randomised, so
a re-signing would put a different message on the wire than the one handled
locally.
This commit is contained in:
Aere Network 2026-09-01 00:03:54 +03:00
parent a9af2de488
commit 7242a2c388
14 changed files with 1838 additions and 11 deletions

View File

@ -13,11 +13,11 @@ a3b93f2602e9755d91358c9b3f473235f4d40ae23d912f797cdb03ed7e1348f9 consensus/comm
e32a03de9f1452bd7444a33b084516ae3399d7a5ae7430088714f5656d3a22ea consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/BftExtraData.java
3ab425b5b5f7d7c2199065a3905623d5c1c4c5922d892c6c4275415b0c69a2b4 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/FalconSeal.java
db1e80115ce59c8281fbf046cb37e153a49fe017c1675afd200e8125d3f6b538 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/FalconSealScheme.java
4b6de4abba87246b18c26fb18a65dfc68831aa981dffefe15eeb2c075fdfcf0f consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/FalconSealSupport.java
789db234a90f118cdf881653ee2bb7ad4a229a685315b04822147ffffe6a3c13 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/FalconSealSupport.java
e8c8111a343cc993b950ac4e5b3992f16b8ee3d34c8827f00dd80d017884335b consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/HybridSealProducer.java
03043d2360c35de3b6f27d029b7ee83cb6c964f4b827a504799607b8bfe1cdcc consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/HybridSealSupport.java
65e33e639c58adb30045e88c770460757305acab31ac884e4c6b36f1c410ef12 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/HybridSignerRegistry.java
afe13adaa495c4d0a9ada14df555afcbc6c8d46d8883730ab1ce201499eebc5f consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchor.java
ecd0e865aa48e461618048c13c46d9b54095dfa2e14180750f49b90faf49302a consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchor.java
852113d420f2c6b82945423a18591c3b2bd1018febe1a52e8b4e42a996c85114 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorConfig.java
c6285ca43331781e1d350a07d58256d02b8b3a74b96b6469c0dca33ecfb6c8cd consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorLapse.java
f27952a25bad02d2939a356f1bac8800ff8fb08023c6bdfb1c6d0e51b5a206da consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorNotReadyException.java
@ -82,14 +82,16 @@ e0bc7a465dbb2dd7efa5f1830211a1a45c6554c300b2088dd70bb9677b0ac217 consensus/comm
e65ba92e288aebe768909b1fee5c0b8850be74b3658c6242e6abdb91409a9521 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqV2Fixture.java
56426ba6bb10df29f5ef5f5959b1646dda5a6d9e7db9904ceb7ad1070ad750b8 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PreparePqAttachGateTest.java
e355b147fdda48c14cb6dc09b1bc1e7a7ccae3f9b709af8f8483f896086dbe4d consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/ProposalPqAttachGateTest.java
45a881d122a7ffa5ff4d2db38a2750e3918055593c51e1b1241342905a30565d consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/RoundChangePqAttachGateTest.java
d48d62c6ea0784864a5987a804a28855265b2180c409ba3c5b4434d21b805e1d consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/SealSchemeAgilityTest.java
22ade1aa2d8254d1f7ff02bbbcb4d02155546a52fca3421b61eea1129b73ee48 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/messagewrappers/Commit.java
271f1cf0cf9ad53bd40d6a99b12666aace3f878bc6ca5f1297ea7284232a735f consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/network/QbftMessageTransmitter.java
597d96d64c9394c79d5084a1ab5df884b2266efe315c0f2d563bfc17b7421480 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/network/QbftMessageTransmitter.java
03173f2029f767e10cc978b5576b9557efa783dab2ef105aa856f6d5e08b9352 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/CommitPayload.java
e577fe2c90ac2cf8bff2145376f44284cad1562c7d09b2eb6da9899849cdd882 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/MessageFactory.java
d365bad33f3aee4597c630071ca1f727ddd8bb6ab6515fccbce0a07842037adf consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/MessageFactory.java
861a2e6f81df1c33755f28ca50bf88b6b0b197f7a7a1d646c165788d192160ca consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/PreparePayload.java
428e11639fb24ae4b04972457714445a5cd59d7946135a8b7362123461fa690f consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/ProposalPayload.java
64c3ab6f9b67eb2ed126bca89ffc0af49bebc17bc8a41b56efc19e51188ca75d consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/QbftBlockHeightManager.java
2e133e92dabb6b7add09820f9239050bd8d3b9ad63c16ed7f6e725a9694f2232 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/RoundChangePayload.java
51eddf3cb8e37489abd35e6f4c36294a13ec76337bbbbfd54c07584085dceaac consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/QbftBlockHeightManager.java
252f9a86ca17cc6362780af264dbc4da4d842191467427a809ff1e4baa7640af consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/QbftController.java
609890459ba6be78d0b91c04e80927231e8d61a0af822c4049ff0972530ab7ea consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/QbftRound.java
a22d857867c5b7c9ccdf9185c661870eafe6b481573189202b2cc34d9fbf3e2d consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/RoundState.java
@ -98,12 +100,16 @@ a22d857867c5b7c9ccdf9185c661870eafe6b481573189202b2cc34d9fbf3e2d consensus/qbft
ec53f8e401a19345a067223f482f27a95cb9c3153d5bee0bfea9fd622bc0db01 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/PqCommitEnforcement.java
5ab52433f770537e838b15e23abfc7b24eaa733ea26577451d5e32226d617879 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/PqPrepareEnforcement.java
742611d52091a6c138cdff60c5f7f0bfb2fc33e567f520f3e86878fc086e77d9 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/PqProposalEnforcement.java
afb487f8c1dd87e323fdc1153ecfdf8ec6cb5a10e0726783d4214c07a3fa9a95 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/PqRoundChangeEnforcement.java
60eb9c5a8e3f464aa42915909e402bf59af9bad4e28631538dcf2f5ca52a9569 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/PrepareValidator.java
8ab72d5558842779a6df6d5d4d7c06479ac3a19746b51bdd00d5168084f0b590 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/ProposalPayloadValidator.java
54c657bb4759623faf2159417ec0255187e1919215e43f7e7cefb71e4514c8cb consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/RoundChangePayloadValidator.java
e2107737579c16b73cbd2c9b50e90abcd6c910309286fb862cef780555ca43a5 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/network/ProposalSealPlumbingTest.java
6da7d76a2ddc3dcab2a6d54d60615bb54182594f230e858cfd0c13b2626b76e0 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/network/RoundChangeSealPlumbingTest.java
36dd77cdea980e4de0090e86ae2ca75d1af62a9477fe056c7fdde06422617898 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/payload/CommitPayloadHybridTest.java
fe25255de4b5fa6d86412fa4c48218180f549f0a4ffea5b5cc0a7d19a5be4cd9 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/payload/PreparePayloadPqTest.java
a994d5638940ea90f37ed6f3ed5eebc24781707f2f4555c7a079d1251ddbf893 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/payload/ProposalPayloadPqTest.java
df5d7d2119cf0864a8c2dce50f4f69bd8b64804cfda7f7366c013b304af0a6e7 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/payload/RoundChangePayloadPqTest.java
0bb2c1e31a7f29e9b07fb7030567746fc6d79460ed4739d5ecafac44501f7bb5 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/statemachine/PqLateSealSalvageTest.java
8c40173c7b248edec7c29ee9e049f9e269f4725b637bceb8734735fc73eb9ca2 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/CommitValidatorPqEnforcementTest.java
401c25f63abb248dfe60b69676a34ea255bb669b4bc58e45916f6eecea3cb870 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqCommitEnforcementTest.java
@ -112,9 +118,11 @@ c0853ed97c53d54951e25ad6d0b70c0299dedd859cc7c44da64e3751d0e0de33 consensus/qbft
5a47d247d7bcda77b57f5c906c3cee1af826785416012fafe9cfa49d63671388 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqPrepareAgilityTest.java
1939e33cc8ea81782e5e17d68e2b59c001e4207ed8ef07acd1d2a166a047dcb3 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqPrepareEnforcementTest.java
afbd5e820e5dabfbaa924be2730019cc133fcbd03095f9a8a4b783d8b5e79c90 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqProposalEnforcementTest.java
53a4e0246a55c239ed44a61271e6d38c7901e1101dfa53ebd44d7a7f5bcb74ac consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqRoundChangeEnforcementTest.java
dc9f9e862a11f0135d26976176a1d1adac3e84fd5f1e06572d8727262cb3374f consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PrepareValidatorPqWiringTest.java
2c612868dfdd61d86cadc4ca9b34faacf4e43aca88878feb72ac140e1321ef5d consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/ProposalPayloadValidatorPqWiringTest.java
83dd971f66ed63103d09db5283240556bf1db1e1925c2a07808c7f59d47433ad consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/RoundChangeJustificationPqTest.java
8f6abb4297df964e934cfeabe51f365478e917b73cd6909f621c9e201e280aa5 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/RoundChangePayloadValidatorPqWiringTest.java
79dea5e85b968af696bf57d51d0de175a4c025537200402e3594a4a8290d1a29 consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/QbftBlockHeaderValidationRulesetFactory.java
7d0a75818ac64b601b79c32b8289eedda8e2300dbb0f5e9186084f4e01b04d26 consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/QbftExtraDataCodec.java
47fbdd8639c464bf19bac3a0ba540af4d99f79e9df02c2ad586a19a2aebd037d consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/adaptor/QbftBlockCreatorAdaptor.java

View File

@ -144,6 +144,10 @@ public final class FalconSealSupport {
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;
@ -3681,6 +3685,105 @@ public final class FalconSealSupport {
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.
*
* <p>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.
*
* <p>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<FalconSeal> 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<byte[]> 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.
*

View File

@ -128,6 +128,21 @@ public final class PqAnchor {
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.
*
* <p>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<FalconSeal> BY_INDEX =
Comparator.comparingInt(FalconSeal::getValidatorIndex);
@ -328,6 +343,87 @@ public final class PqAnchor {
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.
*
* <p>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.
*
* <p>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.
*

View File

@ -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.
*
* <p>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.
*
* <p>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();
}
}

View File

@ -244,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);
}
}

View File

@ -226,6 +226,25 @@ public class MessageFactory {
public RoundChange createRoundChange(
final ConsensusRoundIdentifier roundIdentifier,
final Optional<PreparedCertificate> 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<PreparedCertificate> preparedRoundData,
final Optional<FalconSeal> falconSeal) {
final RoundChangePayload payload;
if (preparedRoundData.isPresent()) {
@ -236,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),
@ -246,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(),

View File

@ -0,0 +1,211 @@
/*
* Copyright ConsenSys AG.
*
* 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 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;
import java.util.Objects;
import java.util.Optional;
import com.google.common.base.MoreObjects;
/**
* The Round change payload.
*
* <p>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 <b>byte for byte as
* upstream</b>, which is the property that lets the binary be rolled onto a live fleet without a
* flag day.
*
* <p><b>NOTHING EMITS SUCH A ROUND-CHANGE YET.</b> 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.
*
* <p><b>What the seal signs is NOT this file's business:</b> 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.
*
* <p><b>Why the trailing element is parsed strictly:</b> 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> preparedRoundMetadata;
private final Optional<FalconSeal> falconSeal;
/**
* 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> preparedRoundMetadata,
final Optional<FalconSeal> 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> preparedRoundMetadata) {
this(roundChangeIdentifier, preparedRoundMetadata, Optional.empty());
}
@Override
public ConsensusRoundIdentifier getRoundIdentifier() {
return roundChangeIdentifier;
}
/**
* Gets prepared round metadata.
*
* @return the prepared round metadata
*/
public Optional<PreparedRoundMetadata> getPreparedRoundMetadata() {
return preparedRoundMetadata;
}
/**
* The author's post-quantum seal, when the message carries one.
*
* @return the seal, or empty
*/
public Optional<FalconSeal> getFalconSeal() {
return falconSeal;
}
@Override
public void writeTo(final RLPOutput rlpOutput) {
// RLP encode of the message data content (round identifier and prepared certificate)
rlpOutput.startList();
writeConsensusRound(rlpOutput);
rlpOutput.startList();
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();
}
/**
* Read from rlp input and return round change payload.
*
* @param rlpInput the rlp input
* @return the round change payload
*/
public static RoundChangePayload readFrom(final RLPInput rlpInput) {
rlpInput.enterList();
final ConsensusRoundIdentifier roundIdentifier = readConsensusRound(rlpInput);
final Optional<PreparedRoundMetadata> preparedRoundMetadata;
rlpInput.enterList();
if (rlpInput.isEndOfCurrentList()) {
preparedRoundMetadata = Optional.empty();
} else {
preparedRoundMetadata = Optional.of(PreparedRoundMetadata.readFrom(rlpInput));
}
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> 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, falconSeal);
}
@Override
public int getMessageType() {
return TYPE;
}
@Override
public boolean equals(final Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
RoundChangePayload that = (RoundChangePayload) o;
return Objects.equals(roundChangeIdentifier, that.roundChangeIdentifier)
&& Objects.equals(preparedRoundMetadata, that.preparedRoundMetadata)
&& Objects.equals(falconSeal, that.falconSeal);
}
@Override
public int hashCode() {
return Objects.hash(roundChangeIdentifier, preparedRoundMetadata, falconSeal);
}
@Override
public String toString() {
return MoreObjects.toStringHelper(this)
.add("roundChangeIdentifier", roundChangeIdentifier)
.add("preparedRoundMetadata", preparedRoundMetadata)
.add("falconSeal", falconSeal.isPresent() ? "present" : "absent")
.toString();
}
}

View File

@ -329,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<org.hyperledger.besu.consensus.common.bft.FalconSeal> 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.
@ -341,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<org.hyperledger.besu.consensus.common.bft.FalconSeal> roundChangeSealFor(
final ConsensusRoundIdentifier roundIdentifier,
final Optional<PreparedCertificate> 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

View File

@ -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).
*
* <p>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.
*
* <p><b>WHAT THIS LAYER BUYS, said precisely.</b> 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.
*
* <p><b>WHAT THE SEAL SIGNS.</b> 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.
*
* <p><b>DISARMED BY DEFAULT.</b> 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.
*
* <p>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<String> refusal(
final long height,
final int targetRound,
final Address author,
final Optional<PreparedRoundMetadata> preparedRoundMetadata,
final Optional<FalconSeal> 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();
}
}

View File

@ -0,0 +1,143 @@
/*
* Copyright ConsenSys AG.
*
* 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.payload.SignedData;
import org.hyperledger.besu.consensus.qbft.core.payload.PreparedRoundMetadata;
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;
/**
* 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)
*
* <p>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.
*
* <p>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 {
private static final String ERROR_PREFIX = "Invalid RoundChange Payload";
private static final Logger LOG = LoggerFactory.getLogger(RoundChangePayloadValidator.class);
private final Collection<Address> validators;
private final long chainHeight;
private final PqRoundChangeEnforcement pqEnforcement;
/**
* 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<Address> 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<Address> validators,
final long chainHeight,
final PqRoundChangeEnforcement pqEnforcement) {
this.validators = validators;
this.chainHeight = chainHeight;
this.pqEnforcement = pqEnforcement;
}
/**
* Validate.
*
* @param signedPayload the signed payload
* @return the boolean
*/
public boolean validate(final SignedData<RoundChangePayload> signedPayload) {
if (!validators.contains(signedPayload.getAuthor())) {
LOG.info("{}: did not originate from a recognized validator.", ERROR_PREFIX);
return false;
}
final RoundChangePayload payload = signedPayload.getPayload();
if (payload.getRoundIdentifier().getSequenceNumber() != chainHeight) {
LOG.info("{}: did not target expected height", ERROR_PREFIX);
return false;
}
final int targetRound = payload.getRoundIdentifier().getRoundNumber();
if (targetRound <= 0) {
LOG.info("{}: must contain a positive target round number", ERROR_PREFIX);
return false;
}
if (payload.getPreparedRoundMetadata().isPresent()) {
final PreparedRoundMetadata metadata = payload.getPreparedRoundMetadata().get();
if (metadata.getPreparedRound() >= targetRound) {
LOG.info("{}: prepared metadata is from a round ahead of target round", ERROR_PREFIX);
return false;
}
if (metadata.getPreparedRound() < 0) {
LOG.info("{}: prepared metadata is from a negative round number", ERROR_PREFIX);
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<String> 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;
}
}

View File

@ -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.
*
* <p>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<FalconSeal> 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<MessageData> 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();
}
}

View File

@ -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).
*
* <p>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.
*
* <p>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);
}
}

View File

@ -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.
*
* <ol>
* <li>below the arming height NOTHING changes - the condition for the binary to sit on the fleet;
* <li>above it, a ROUND-CHANGE without a seal is refused;
* <li>a seal from ANOTHER validator does not vouch for this author;
* <li>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;
* <li>a seal from a DIFFERENT TARGET ROUND does not pass;
* <li>a seal from a BARE round-change does not pass on one claiming a prepared block, and vice
* versa;
* <li>an index the registry binds to nobody does not pass;
* <li>a mistyped configuration REFUSES, it does not disarm;
* <li>the enforcement gate and the emission gate are two different switches.
* </ol>
*
* <p>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<Integer, Address> bindings;
final Map<Integer, byte[]> keys;
TestRegistry(final Map<Integer, Address> bindings, final Map<Integer, byte[]> 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<PreparedRoundMetadata> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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");
}
}

View File

@ -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?
*
* <p>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();
}
}