From 7242a2c38833ae87c2b3435efbb5281a078f7c25 Mon Sep 17 00:00:00 2001 From: Aere Network Date: Tue, 1 Sep 2026 00:03:54 +0300 Subject: [PATCH] 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. --- anchor/MANIFEST-sha256.txt | 18 +- .../common/bft/FalconSealSupport.java | 103 +++++ .../besu/consensus/common/bft/PqAnchor.java | 96 ++++ .../bft/RoundChangePqAttachGateTest.java | 108 +++++ .../core/network/QbftMessageTransmitter.java | 14 + .../qbft/core/payload/MessageFactory.java | 24 +- .../qbft/core/payload/RoundChangePayload.java | 211 +++++++++ .../statemachine/QbftBlockHeightManager.java | 52 ++- .../validation/PqRoundChangeEnforcement.java | 209 +++++++++ .../RoundChangePayloadValidator.java | 143 ++++++ .../network/RoundChangeSealPlumbingTest.java | 88 ++++ .../payload/RoundChangePayloadPqTest.java | 236 ++++++++++ .../PqRoundChangeEnforcementTest.java | 422 ++++++++++++++++++ ...undChangePayloadValidatorPqWiringTest.java | 125 ++++++ 14 files changed, 1838 insertions(+), 11 deletions(-) create mode 100644 anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/RoundChangePqAttachGateTest.java create mode 100644 anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/RoundChangePayload.java create mode 100644 anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/PqRoundChangeEnforcement.java create mode 100644 anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/RoundChangePayloadValidator.java create mode 100644 anchor/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/network/RoundChangeSealPlumbingTest.java create mode 100644 anchor/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/payload/RoundChangePayloadPqTest.java create mode 100644 anchor/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqRoundChangeEnforcementTest.java create mode 100644 anchor/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/RoundChangePayloadValidatorPqWiringTest.java diff --git a/anchor/MANIFEST-sha256.txt b/anchor/MANIFEST-sha256.txt index 59fedfe..9057423 100644 --- a/anchor/MANIFEST-sha256.txt +++ b/anchor/MANIFEST-sha256.txt @@ -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 diff --git a/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/FalconSealSupport.java b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/FalconSealSupport.java index 65fa080..7049b84 100644 --- a/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/FalconSealSupport.java +++ b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/FalconSealSupport.java @@ -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. + * + *

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. * diff --git a/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchor.java b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchor.java index 80bbad6..32c634e 100644 --- a/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchor.java +++ b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchor.java @@ -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. + * + *

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

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. * diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/RoundChangePqAttachGateTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/RoundChangePqAttachGateTest.java new file mode 100644 index 0000000..19c1055 --- /dev/null +++ b/anchor/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/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/network/QbftMessageTransmitter.java b/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/network/QbftMessageTransmitter.java index f9a92ab..18437e3 100644 --- a/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/network/QbftMessageTransmitter.java +++ b/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/network/QbftMessageTransmitter.java @@ -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); + } } diff --git a/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/MessageFactory.java b/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/MessageFactory.java index 5994ab2..11426d6 100644 --- a/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/MessageFactory.java +++ b/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/MessageFactory.java @@ -226,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()) { @@ -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(), diff --git a/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/RoundChangePayload.java b/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/RoundChangePayload.java new file mode 100644 index 0000000..32c0613 --- /dev/null +++ b/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/RoundChangePayload.java @@ -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. + * + *

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 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 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 + public ConsensusRoundIdentifier getRoundIdentifier() { + return roundChangeIdentifier; + } + + /** + * Gets prepared round metadata. + * + * @return the prepared round metadata + */ + public Optional getPreparedRoundMetadata() { + 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) + 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; + + 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 = 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(); + } +} diff --git a/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/QbftBlockHeightManager.java b/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/QbftBlockHeightManager.java index f8cd071..bc40440 100644 --- a/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/QbftBlockHeightManager.java +++ b/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/QbftBlockHeightManager.java @@ -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 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 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/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/PqRoundChangeEnforcement.java b/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/PqRoundChangeEnforcement.java new file mode 100644 index 0000000..1b952f6 --- /dev/null +++ b/anchor/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/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/RoundChangePayloadValidator.java b/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/RoundChangePayloadValidator.java new file mode 100644 index 0000000..718651a --- /dev/null +++ b/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/RoundChangePayloadValidator.java @@ -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) + * + *

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 { + + private static final String ERROR_PREFIX = "Invalid RoundChange Payload"; + private static final Logger LOG = LoggerFactory.getLogger(RoundChangePayloadValidator.class); + + private final Collection

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
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; + } + + /** + * Validate. + * + * @param signedPayload the signed payload + * @return the boolean + */ + public boolean validate(final SignedData 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 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/anchor/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/network/RoundChangeSealPlumbingTest.java b/anchor/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/network/RoundChangeSealPlumbingTest.java new file mode 100644 index 0000000..8789258 --- /dev/null +++ b/anchor/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/anchor/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/payload/RoundChangePayloadPqTest.java b/anchor/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/payload/RoundChangePayloadPqTest.java new file mode 100644 index 0000000..efbf517 --- /dev/null +++ b/anchor/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/anchor/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqRoundChangeEnforcementTest.java b/anchor/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqRoundChangeEnforcementTest.java new file mode 100644 index 0000000..5e6b486 --- /dev/null +++ b/anchor/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/anchor/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/RoundChangePayloadValidatorPqWiringTest.java b/anchor/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/RoundChangePayloadValidatorPqWiringTest.java new file mode 100644 index 0000000..b906ec0 --- /dev/null +++ b/anchor/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(); + } +}