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

The remaining hot-path gap, stated precisely because the imprecise version oversells: with
PREPARE enforcement armed, an ECDSA-breaking adversary cannot finalize anything, since
"prepared" needs a full quorum of PREPAREs. What forged proposals could still do is OPEN
rounds and waste them. With proposal enforcement armed, a proposal without a valid Falcon
seal from its own proposer does not open a round.

Same pattern as the PREPARE layer, deliberately: an optional fifth payload element that
leaves the unsealed encoding byte-identical to upstream; a third, separate emission gate;
enforcement self-wired in the payload validator so no refactor can drop it silently; its
own domain over (chainId, height, round, digest), tested in both directions so an honest
offer seal cannot be replayed as a vote nor a vote seal as an offer.

Nothing is armed anywhere: the properties are unset, and unset means never. The armed-
layers table in the README says so, and says what stays classical: the ROUND-CHANGE
message itself and node-level devp2p authentication.

1,042 tests, 0 failures, counted from the XML. The enforcement's negative control ran at
5 of 5: four planted defects each turned the suite red, the untouched source stayed green.
This commit is contained in:
Aere Network 2026-08-30 13:38:33 +03:00
parent 4ef6565275
commit 3afe906fb2
13 changed files with 1635 additions and 26 deletions

View File

@ -13,11 +13,11 @@ a3b93f2602e9755d91358c9b3f473235f4d40ae23d912f797cdb03ed7e1348f9 consensus/comm
e32a03de9f1452bd7444a33b084516ae3399d7a5ae7430088714f5656d3a22ea consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/BftExtraData.java 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 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 db1e80115ce59c8281fbf046cb37e153a49fe017c1675afd200e8125d3f6b538 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/FalconSealScheme.java
167af0d87b017e1e15ecd2105426b009b6bdaeec5b52f7c1f710d212b0330765 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/FalconSealSupport.java 4b6de4abba87246b18c26fb18a65dfc68831aa981dffefe15eeb2c075fdfcf0f 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 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 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 65e33e639c58adb30045e88c770460757305acab31ac884e4c6b36f1c410ef12 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/HybridSignerRegistry.java
e9b30713dea69601dd29b3e460d81585a21f5c48286d18f4d3bc5d4c4ec83c19 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchor.java afe13adaa495c4d0a9ada14df555afcbc6c8d46d8883730ab1ce201499eebc5f 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 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 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 f27952a25bad02d2939a356f1bac8800ff8fb08023c6bdfb1c6d0e51b5a206da consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorNotReadyException.java
@ -81,23 +81,28 @@ e0bc7a465dbb2dd7efa5f1830211a1a45c6554c300b2088dd70bb9677b0ac217 consensus/comm
9a371ec367cda846640c6da18c8bf7d921a6882cfc024dc0c86d4f965cae0c63 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqStartupHistoryTest.java 9a371ec367cda846640c6da18c8bf7d921a6882cfc024dc0c86d4f965cae0c63 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqStartupHistoryTest.java
e65ba92e288aebe768909b1fee5c0b8850be74b3658c6242e6abdb91409a9521 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqV2Fixture.java 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 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
d48d62c6ea0784864a5987a804a28855265b2180c409ba3c5b4434d21b805e1d consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/SealSchemeAgilityTest.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 22ade1aa2d8254d1f7ff02bbbcb4d02155546a52fca3421b61eea1129b73ee48 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/messagewrappers/Commit.java
046c67d18ba623cd01390b6ffef945b0dfa245fbf0cc79889b7670ab465b7992 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/network/QbftMessageTransmitter.java 046c67d18ba623cd01390b6ffef945b0dfa245fbf0cc79889b7670ab465b7992 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 03173f2029f767e10cc978b5576b9557efa783dab2ef105aa856f6d5e08b9352 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/CommitPayload.java
37bda0f126c1333cf854a33c64cc98cedcdc8c0c2ec4cc3ba1f6736e7302ddff consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/MessageFactory.java e577fe2c90ac2cf8bff2145376f44284cad1562c7d09b2eb6da9899849cdd882 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 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 64c3ab6f9b67eb2ed126bca89ffc0af49bebc17bc8a41b56efc19e51188ca75d 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 252f9a86ca17cc6362780af264dbc4da4d842191467427a809ff1e4baa7640af consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/QbftController.java
07834160f12c4f28c567959c358176708b17742cc7cc4f49542b165efef5f1c8 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/QbftRound.java a56a86a95e3ff7de3c9b22ee6e91c88ed713a3c065cadbb3b6fe205698a4c2c6 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 a22d857867c5b7c9ccdf9185c661870eafe6b481573189202b2cc34d9fbf3e2d consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/RoundState.java
601b0cccf32f8ebe327c7581e0e9b3f9f67de49d9fb646326d6b49ef2138c641 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/types/QbftBlockCreator.java 601b0cccf32f8ebe327c7581e0e9b3f9f67de49d9fb646326d6b49ef2138c641 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/types/QbftBlockCreator.java
7f9fb13a7bbe3015bbef6329f750f2b35fb3744895cd3e6599af90450fab6a73 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/CommitValidator.java 7f9fb13a7bbe3015bbef6329f750f2b35fb3744895cd3e6599af90450fab6a73 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/CommitValidator.java
ec53f8e401a19345a067223f482f27a95cb9c3153d5bee0bfea9fd622bc0db01 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/PqCommitEnforcement.java 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 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
60eb9c5a8e3f464aa42915909e402bf59af9bad4e28631538dcf2f5ca52a9569 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/PrepareValidator.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
36dd77cdea980e4de0090e86ae2ca75d1af62a9477fe056c7fdde06422617898 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/payload/CommitPayloadHybridTest.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 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
0bb2c1e31a7f29e9b07fb7030567746fc6d79460ed4739d5ecafac44501f7bb5 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/statemachine/PqLateSealSalvageTest.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 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 401c25f63abb248dfe60b69676a34ea255bb669b4bc58e45916f6eecea3cb870 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqCommitEnforcementTest.java
@ -105,7 +110,9 @@ c0853ed97c53d54951e25ad6d0b70c0299dedd859cc7c44da64e3751d0e0de33 consensus/qbft
5e0bb0ecc77ffb06f232e1aa81cca6870e5455af7c2846ae54d5f477f1bbb88b consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqHybridEnforcementTest.java 5e0bb0ecc77ffb06f232e1aa81cca6870e5455af7c2846ae54d5f477f1bbb88b consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqHybridEnforcementTest.java
5a47d247d7bcda77b57f5c906c3cee1af826785416012fafe9cfa49d63671388 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqPrepareAgilityTest.java 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 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
dc9f9e862a11f0135d26976176a1d1adac3e84fd5f1e06572d8727262cb3374f consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PrepareValidatorPqWiringTest.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 83dd971f66ed63103d09db5283240556bf1db1e1925c2a07808c7f59d47433ad consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/RoundChangeJustificationPqTest.java
79dea5e85b968af696bf57d51d0de175a4c025537200402e3594a4a8290d1a29 consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/QbftBlockHeaderValidationRulesetFactory.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 7d0a75818ac64b601b79c32b8289eedda8e2300dbb0f5e9186084f4e01b04d26 consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/QbftExtraDataCodec.java

View File

@ -147,6 +147,8 @@ This matters more than the file list, so it is stated before it.
| Legacy per-block Falcon rule (`aere.falcon.forkBlock`) | yes | **no** — retired at the anchor block | | Legacy per-block Falcon rule (`aere.falcon.forkBlock`) | yes | **no** — retired at the anchor block |
| Post-quantum seal on PREPARE, emission | yes | **no** — no node sets the property | | Post-quantum seal on PREPARE, emission | yes | **no** — no node sets the property |
| Post-quantum seal on PREPARE, enforcement | yes | **no** — no node sets the property | | Post-quantum seal on PREPARE, enforcement | yes | **no** — no node sets the property |
| Post-quantum seal on the PROPOSAL, emission | yes | **no** — no node sets the property |
| Post-quantum seal on the PROPOSAL, enforcement | yes | **no** — no node sets the property |
| Hybrid Falcon + SLH-DSA certificate | yes | **no** — needs new keys, not generated | | Hybrid Falcon + SLH-DSA certificate | yes | **no** — needs new keys, not generated |
Everything in the "no" rows is **disarmed by absence, not by a flag**: the properties that arm them Everything in the "no" rows is **disarmed by absence, not by a flag**: the properties that arm them
@ -160,6 +162,16 @@ unsealed PREPAREs never reaches the prepared state, so it never sends COMMIT at
it has no safety net during an activation, which is why it is not armed anywhere and why its it has no safety net during an activation, which is why it is not armed anywhere and why its
activation height is a decision that has not been taken. activation height is a decision that has not been taken.
The PROPOSAL layer (added 2026-08-30) closes the remaining hot-path gap, and its claim is stated
precisely because the imprecise version oversells: an adversary who breaks ECDSA cannot finalize
anything while the PREPARE layer is armed, since "prepared" needs a full quorum of PREPAREs. What
forged proposals could still do is OPEN rounds and waste them. With proposal enforcement armed, a
proposal without a valid Falcon seal from its own proposer does not open a round. The seal signs
its own domain over (chainId, height, round, digest), so an honest proposal seal cannot be replayed
as a vote nor a vote seal as a proposal - both directions are tested. What remains classical: the
ROUND-CHANGE message itself (its embedded justifications are already coupled to PREPARE
enforcement) and node-level devp2p authentication.
### The files ### The files
- `consensus/common/.../bft/` — the anchor itself: configuration, the digest, the seal cache and - `consensus/common/.../bft/` — the anchor itself: configuration, the digest, the seal cache and

View File

@ -136,10 +136,14 @@ public final class FalconSealSupport {
private static final String ANCHOR_SLOT = private static final String ANCHOR_SLOT =
"0000000000000000000000000000000000000000000000000000000000000000"; "0000000000000000000000000000000000000000000000000000000000000000";
/** Cate sigilii a emis acest nod pe PREPARE. Vezi {@link #preparesSealed()}. */ /** How many seals this node emitted on its own PREPAREs. See {@link #preparesSealed()}. */
private final java.util.concurrent.atomic.AtomicLong preparesSealed = private final java.util.concurrent.atomic.AtomicLong preparesSealed =
new java.util.concurrent.atomic.AtomicLong(); new java.util.concurrent.atomic.AtomicLong();
/** How many seals this node emitted on its own PROPOSALs since startup. */
private final java.util.concurrent.atomic.AtomicLong proposalsSealed =
new java.util.concurrent.atomic.AtomicLong();
private final boolean signingEnabled; private final boolean signingEnabled;
private final int localIndex; private final int localIndex;
private final FalconPrivateKeyParameters localPrivateKey; private final FalconPrivateKeyParameters localPrivateKey;
@ -3465,9 +3469,10 @@ public final class FalconSealSupport {
* Property naming the first height at which this node ATTACHES a post-quantum seal to its own * Property naming the first height at which this node ATTACHES a post-quantum seal to its own
* PREPARE messages. Absent = never, which is the configuration of every node today. * PREPARE messages. Absent = never, which is the configuration of every node today.
* *
* <p>SEPARAT de {@code aere.falcon.attachBlock}, si separarea e obligatorie: daca emiterea pe * <p>SEPARATE from {@code aere.falcon.attachBlock}, and the separation is mandatory: if PREPARE
* PREPARE ar porni odata cu cea pe commit, ridicarea binarului pe flota ar deveni o zi de flag. * emission started together with commit emission, rolling the binary onto the fleet would become
* Asa, binarul poate sta luni de zile pe toate nodurile inainte ca vreunul sa emita ceva nou. * a flag day. This way the binary can sit on every node for months before any of them emits
* anything new.
*/ */
public static final String PREPARE_ATTACH_PROPERTY = "aere.pq.preparePq.attachBlock"; public static final String PREPARE_ATTACH_PROPERTY = "aere.pq.preparePq.attachBlock";
@ -3576,6 +3581,106 @@ public final class FalconSealSupport {
return preparesSealed.get(); return preparesSealed.get();
} }
/**
* Property naming the first height at which this node ATTACHES a post-quantum seal to its own
* PROPOSAL messages. Absent = never, which is the configuration of every node today.
*
* <p>SEPARATE from both the commit and the PREPARE gates, same reason: three layers that arm on
* three independent decisions must not share a switch, or the day one is turned on becomes a flag
* day for the others.
*/
public static final String PROPOSAL_ATTACH_PROPERTY = "aere.pq.proposalPq.attachBlock";
/** Environment fallback for {@link #PROPOSAL_ATTACH_PROPERTY}. */
public static final String PROPOSAL_ATTACH_ENV = "AERE_PQ_PROPOSALPQ_ATTACHBLOCK";
/**
* The configured PROPOSAL attachment height, read fresh on every call.
*
* <p>Absent = {@link Long#MAX_VALUE}, i.e. never. A value that is PRESENT but unreadable REFUSES
* loudly instead of disarming - the anchor loader's lesson, once per gate, every gate.
*
* @return the height, or Long.MAX_VALUE when unset
*/
public static long proposalAttachBlock() {
final String raw = resolve(PROPOSAL_ATTACH_PROPERTY, PROPOSAL_ATTACH_ENV);
if (raw == null || raw.isBlank()) {
return Long.MAX_VALUE;
}
try {
final long v = Long.parseLong(raw.trim());
if (v < 0) {
throw new NumberFormatException("negative");
}
return v;
} catch (final NumberFormatException e) {
throw new ActivationConfigException(
ActivationConfigException.Kind.SYNTAX,
"AERE-PQC-PROPOSAL-CONF-01",
"AERE PQ PROPOSAL: "
+ PROPOSAL_ATTACH_PROPERTY
+ " is set to '"
+ raw
+ "', which is not a non-negative block height. A node must REFUSE to start rather "
+ "than silently run with PROPOSAL attachment disarmed: a disarmed node looks exactly "
+ "like a correctly configured one until the day it matters.");
}
}
/**
* Sign this node's own PROPOSAL, when the PROPOSAL attachment gate is open at this height.
*
* <p>Same three requirements as {@link #signPrepare}, for the same three reasons: a key, the
* commit-layer coverage conditions, and its own height. NEVER throws except for the strict
* configuration case: a signing failure is a log line and an empty value, because the ECDSA path
* must not be disturbed.
*
* @param blockNumber the height being proposed
* @param message the domain-separated PROPOSAL message (see PqAnchor.proposalMessage)
* @return the seal, or empty when any gate is shut
*/
public Optional<FalconSeal> signProposal(final long blockNumber, final Bytes32 message) {
if (!signingEnabled) {
return Optional.empty();
}
if (!attachmentArmed(blockNumber)) {
return Optional.empty();
}
if (blockNumber < proposalAttachBlock()) {
return Optional.empty();
}
final java.util.Optional<byte[]> sig =
((FalconSealScheme) SealSchemes.FALCON_512).signWithParams(localPrivateKey, message.toArray());
if (sig.isEmpty()) {
LOG.warn("AERE PQ PROPOSAL: Falcon signing failed (ECDSA path unaffected)");
return Optional.empty();
}
final long n = proposalsSealed.incrementAndGet();
// Same log-volume decision as at PREPARE: a proposal is at most one per round per proposer, so
// this is quieter by nature, but the counter still steps so a testnet has a number to count.
if (n == 1L) {
LOG.info(
"AERE PQ PROPOSAL: this node EMITTED its first post-quantum seal on a PROPOSAL, at "
+ "height {} (gate {}={}). From here on its proposals carry a seal.",
blockNumber,
PROPOSAL_ATTACH_PROPERTY,
proposalAttachBlock());
} else if (n % 500L == 0L) {
LOG.info("AERE PQ PROPOSAL: {} seals emitted on PROPOSALs since startup.", n);
}
return Optional.of(new FalconSeal(localIndex, Bytes.wrap(sig.get())));
}
/**
* How many seals this node has emitted on its own PROPOSALs since startup. Exists so the
* coverage step can be measured, exactly as at PREPARE.
*
* @return the count
*/
public long proposalsSealed() {
return proposalsSealed.get();
}
/** /**
* Verify a Falcon seal against the registry public key for its validator index. Never throws. * Verify a Falcon seal against the registry public key for its validator index. Never throws.
* *

View File

@ -102,10 +102,10 @@ public final class PqAnchor {
* The PREPARE domain label. AERE PQ (2026-08-28). * The PREPARE domain label. AERE PQ (2026-08-28).
* *
* <p>SEPARATE FROM COMMIT, and the separation is a security requirement, not a matter of style. * <p>SEPARATE FROM COMMIT, and the separation is a security requirement, not a matter of style.
* If a PREPARE seal signed the same bytes as a commit seal, an adversary could take the PREPARE * If a PREPARE seal signed the same bytes as a commit seal, an adversary could take a PREPARE
* dat CINSTIT de un validator si sa il lipeasca pe un COMMIT falsificat: semnatura ar verifica, * seal given HONESTLY by a validator and paste it onto a forged COMMIT: the signature would
* si chiar regula pusa sa apere commitul ar fi ocolita. Un singur sir schimbat in preimagine face * verify, and the very rule meant to defend the commit would be bypassed. One changed string in
* cele doua semnaturi netransferabile. * the preimage makes the two signatures non-transferable.
*/ */
public static final String PREPARE_DOMAIN = "AERE-PQ-PREPARE-1"; public static final String PREPARE_DOMAIN = "AERE-PQ-PREPARE-1";
@ -113,6 +113,21 @@ public final class PqAnchor {
public static final Bytes PREPARE_DOMAIN_BYTES = public static final Bytes PREPARE_DOMAIN_BYTES =
Bytes.wrap(PREPARE_DOMAIN.getBytes(StandardCharsets.US_ASCII)); Bytes.wrap(PREPARE_DOMAIN.getBytes(StandardCharsets.US_ASCII));
/**
* The PROPOSAL domain label. AERE PQ (2026-08-30), the next hot-path step after PREPARE.
*
* <p>SEPARATE FROM BOTH PREPARE AND COMMIT, for the same non-transferability reason: a seal a
* proposer gives HONESTLY over its own proposal must not be usable as a vote. A proposal is an
* OFFER, not a vote - the design note of 2026-08-28 measures that "prepared" needs a full quorum
* of PREPAREs, proposer included - so its seal must never count as one. One changed string in the
* preimage is what enforces that at the cryptographic layer instead of by convention.
*/
public static final String PROPOSAL_DOMAIN = "AERE-PQ-PROPOSAL-1";
/** The proposal domain label as raw bytes. */
public static final Bytes PROPOSAL_DOMAIN_BYTES =
Bytes.wrap(PROPOSAL_DOMAIN.getBytes(StandardCharsets.US_ASCII));
/** Orders Falcon seals by their registry index, ascending. */ /** Orders Falcon seals by their registry index, ascending. */
public static final Comparator<FalconSeal> BY_INDEX = public static final Comparator<FalconSeal> BY_INDEX =
Comparator.comparingInt(FalconSeal::getValidatorIndex); Comparator.comparingInt(FalconSeal::getValidatorIndex);
@ -237,8 +252,8 @@ public final class PqAnchor {
* *
* <p>RUNDA E IN PREIMAGINE, spre deosebire de commit, si asta e al doilea lucru care nu se sare: * <p>RUNDA E IN PREIMAGINE, spre deosebire de commit, si asta e al doilea lucru care nu se sare:
* doua PREPARE-uri ale aceluiasi bloc in runde diferite sunt doua afirmatii diferite, iar un * doua PREPARE-uri ale aceluiasi bloc in runde diferite sunt doua afirmatii diferite, iar un
* a seal given in one round must not be movable into another. Without the round, a seal from * a seal given in one round must not be movable into another. Without the round, a seal from a
* PREPARE dintr-o runda esuata ar putea fi refolosit ca sa justifice o alta. * PREPARE of a failed round could be reused to justify another one.
* *
* @param chainId the chain id * @param chainId the chain id
* @param blockNumber the height being prepared * @param blockNumber the height being prepared
@ -273,6 +288,46 @@ public final class PqAnchor {
return Hash.keccak256(out.encoded()); return Hash.keccak256(out.encoded());
} }
/**
* The 32-byte message a proposer's post-quantum PROPOSAL seal signs.
*
* <p>Same shape as {@link #prepareMessage}, different domain, and the ROUND is in the preimage
* for the same reason: a proposal for the same block in a different round is a different
* assertion, and a seal from a failed round must not open another one.
*
* @param chainId the chain id
* @param blockNumber the height being proposed
* @param round the round number of the proposal
* @param digest the digest of the proposed block
* @return the 32-byte message to sign
*/
public static Bytes32 proposalMessage(
final long chainId, final long blockNumber, final int round, final Bytes digest) {
if (blockNumber < 0) {
throw new IllegalArgumentException(
"AERE PQ PROPOSAL: blockNumber must not be negative (got " + blockNumber + ")");
}
if (round < 0) {
throw new IllegalArgumentException(
"AERE PQ PROPOSAL: round must not be negative (got " + round + ")");
}
if (digest == null || digest.size() != 32) {
throw new IllegalArgumentException(
"AERE PQ PROPOSAL: digest must be 32 bytes (got "
+ (digest == null ? "null" : digest.size() + " bytes")
+ ")");
}
final BytesValueRLPOutput out = new BytesValueRLPOutput();
out.startList();
out.writeBytes(PROPOSAL_DOMAIN_BYTES);
out.writeLongScalar(chainId);
out.writeLongScalar(blockNumber);
out.writeLongScalar(round);
out.writeBytes(digest);
out.endList();
return Hash.keccak256(out.encoded());
}
/** /**
* Whether the certificate's validator indices are STRICTLY increasing. * Whether the certificate's validator indices are STRICTLY increasing.
* *

View File

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

View File

@ -82,6 +82,35 @@ public class MessageFactory {
return new Proposal(createSignedMessage(payload), roundChanges, prepares); return new Proposal(createSignedMessage(payload), roundChanges, prepares);
} }
/**
* Create proposal carrying the proposer's optional post-quantum seal (AERE PQ, 2026-08-30).
*
* <p>The seal rides INSIDE the signed payload, so the proposer's ECDSA signature covers it and
* nobody can strip or replace it without failing authorship recovery. With the seal absent this
* overload produces byte-for-byte what the upstream overload produces.
*
* @param roundIdentifier the round identifier
* @param block the block
* @param blockAccessList the block access list
* @param roundChanges the round changes
* @param prepares the prepares
* @param falconSeal the proposer's post-quantum seal, or empty
* @return the proposal
*/
public Proposal createProposal(
final ConsensusRoundIdentifier roundIdentifier,
final QbftBlock block,
final Optional<BlockAccessList> blockAccessList,
final List<SignedData<RoundChangePayload>> roundChanges,
final List<SignedData<PreparePayload>> prepares,
final Optional<FalconSeal> falconSeal) {
final ProposalPayload payload =
new ProposalPayload(roundIdentifier, block, blockEncoder, blockAccessList, falconSeal);
return new Proposal(createSignedMessage(payload), roundChanges, prepares);
}
/** /**
* Create proposal. * Create proposal.
* *

View File

@ -0,0 +1,254 @@
/*
* 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.consensus.qbft.core.types.QbftBlock;
import org.hyperledger.besu.consensus.qbft.core.types.QbftBlockCodec;
import org.hyperledger.besu.ethereum.core.encoding.BlockAccessListDecoder;
import org.hyperledger.besu.ethereum.mainnet.block.access.list.BlockAccessList;
import org.hyperledger.besu.ethereum.rlp.RLPException;
import org.hyperledger.besu.ethereum.rlp.RLPInput;
import org.hyperledger.besu.ethereum.rlp.RLPOutput;
import java.util.Objects;
import java.util.Optional;
import com.google.common.base.MoreObjects;
/**
* The Proposal payload.
*
* <p>AERE PQ (2026-08-30), the hot-path step after PREPARE: a PROPOSAL MAY carry an OPTIONAL
* Falcon-512 seal from its proposer, appended after the block-access-list element, exactly as
* {@code PreparePayload} appends its seal. A proposal without a seal encodes <b>byte for byte as
* upstream</b>, which is the property that lets the binary be rolled onto a live fleet without a
* flag day.
*
* <p><b>NOTHING EMITS SUCH A PROPOSAL YET.</b> This file only makes the wire capable of carrying
* one and of refusing a malformed one. Emission has its own gate
* ({@code aere.pq.proposalPq.attachBlock}); enforcement has its own height
* ({@code aere.pq.proposalPq.forkBlock}); both are absent on every node today, and absent means
* never.
*
* <p><b>What the seal signs is NOT this file's business:</b> its own domain
* {@code AERE-PQ-PROPOSAL-1} over (chainId, height, ROUND, digest) - see
* {@code PqAnchor.proposalMessage}. A proposal is an offer, not a vote: with the commit or PREPARE
* domain, a proposal seal given honestly could be pasted onto a forged vote and counted.
*
* <p><b>Why the trailing element is parsed strictly:</b> the payload is AUTHENTICATED - the author
* is recovered from the signature over the encoded payload. A decoder that silently ignored an
* unknown trailing element would re-encode without it, so two different byte strings would
* authenticate to the same proposer. When a fifth element is present it must be exactly a seal,
* and it must be the last element; otherwise the message is refused. Messages of the upstream
* shapes (three or four elements) are read exactly as upstream reads them.
*/
public class ProposalPayload extends QbftPayload {
private static final int TYPE = QbftV1.PROPOSAL;
private final ConsensusRoundIdentifier roundIdentifier;
private final QbftBlock proposedBlock;
private final QbftBlockCodec blockEncoder;
private final Optional<BlockAccessList> blockAccessList;
private final Optional<FalconSeal> falconSeal;
/**
* Instantiates a new Proposal payload carrying an optional post-quantum seal of its proposer.
*
* @param roundIdentifier the round identifier
* @param proposedBlock the proposed block
* @param blockEncoder the qbft block encoder
* @param blockAccessList the block access list
* @param falconSeal the proposer's post-quantum seal, or empty
*/
public ProposalPayload(
final ConsensusRoundIdentifier roundIdentifier,
final QbftBlock proposedBlock,
final QbftBlockCodec blockEncoder,
final Optional<BlockAccessList> blockAccessList,
final Optional<FalconSeal> falconSeal) {
this.roundIdentifier = roundIdentifier;
this.proposedBlock = proposedBlock;
this.blockEncoder = blockEncoder;
this.blockAccessList = blockAccessList;
this.falconSeal = falconSeal == null ? Optional.empty() : falconSeal;
}
/**
* Instantiates a new Proposal payload, without a post-quantum seal. Encodes byte-for-byte as
* upstream Besu.
*
* @param roundIdentifier the round identifier
* @param proposedBlock the proposed block
* @param blockEncoder the qbft block encoder
* @param blockAccessList the block access list
*/
public ProposalPayload(
final ConsensusRoundIdentifier roundIdentifier,
final QbftBlock proposedBlock,
final QbftBlockCodec blockEncoder,
final Optional<BlockAccessList> blockAccessList) {
this(roundIdentifier, proposedBlock, blockEncoder, blockAccessList, Optional.empty());
}
/**
* Instantiates a new Proposal payload.
*
* @param roundIdentifier the round identifier
* @param proposedBlock the proposed block
* @param blockEncoder the qbft block encoder
*/
public ProposalPayload(
final ConsensusRoundIdentifier roundIdentifier,
final QbftBlock proposedBlock,
final QbftBlockCodec blockEncoder) {
this(roundIdentifier, proposedBlock, blockEncoder, Optional.empty(), Optional.empty());
}
/**
* Read from rlp input and return proposal payload.
*
* @param rlpInput the rlp input
* @param blockEncoder the qbft block encoder
* @return the proposal payload
*/
public static ProposalPayload readFrom(
final RLPInput rlpInput, final QbftBlockCodec blockEncoder) {
rlpInput.enterList();
final ConsensusRoundIdentifier roundIdentifier = readConsensusRound(rlpInput);
final QbftBlock proposedBlock = blockEncoder.readFrom(rlpInput);
final Optional<BlockAccessList> blockAccessList = readBlockAccessList(rlpInput);
// AERE PQ: the OPTIONAL proposer seal [index, signature]. A proposal without one ends the list
// here and decodes to Optional.empty(), so the upstream shapes stay untouched. When a fifth
// element exists it must be a seal and it must be last: an unknown trailing element on an
// authenticated payload is malleability, not extensibility.
Optional<FalconSeal> falconSeal = Optional.empty();
if (!rlpInput.isEndOfCurrentList()) {
rlpInput.enterList();
final int idx = rlpInput.readIntScalar();
final org.apache.tuweni.bytes.Bytes sig = rlpInput.readBytes();
rlpInput.leaveList();
falconSeal = Optional.of(new FalconSeal(idx, sig));
if (!rlpInput.isEndOfCurrentList()) {
throw new RLPException(
"Proposal payload carries elements after the proposer seal; refusing an encoding the "
+ "re-encoder would silently drop");
}
}
rlpInput.leaveList();
return new ProposalPayload(
roundIdentifier, proposedBlock, blockEncoder, blockAccessList, falconSeal);
}
@Override
public void writeTo(final RLPOutput rlpOutput) {
rlpOutput.startList();
writeConsensusRound(rlpOutput);
blockEncoder.writeTo(proposedBlock, rlpOutput);
blockAccessList.ifPresentOrElse((bal) -> bal.writeTo(rlpOutput), rlpOutput::writeNull);
// The seal is written only when present, so a seal-less proposal is identical to upstream.
if (falconSeal.isPresent()) {
final FalconSeal fs = falconSeal.get();
rlpOutput.startList();
rlpOutput.writeIntScalar(fs.getValidatorIndex());
rlpOutput.writeBytes(fs.getSignature());
rlpOutput.endList();
}
rlpOutput.endList();
}
/**
* Gets proposed block.
*
* @return the proposed block
*/
public QbftBlock getProposedBlock() {
return proposedBlock;
}
/**
* Gets block access list.
*
* @return the block access list
*/
public Optional<BlockAccessList> getBlockAccessList() {
return blockAccessList;
}
/**
* The proposer's post-quantum seal, when the message carries one.
*
* @return the seal, or empty
*/
public Optional<FalconSeal> getFalconSeal() {
return falconSeal;
}
@Override
public int getMessageType() {
return TYPE;
}
@Override
public ConsensusRoundIdentifier getRoundIdentifier() {
return roundIdentifier;
}
@Override
public boolean equals(final Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
ProposalPayload that = (ProposalPayload) o;
return Objects.equals(roundIdentifier, that.roundIdentifier)
&& Objects.equals(proposedBlock, that.proposedBlock)
&& Objects.equals(blockAccessList, that.blockAccessList)
&& Objects.equals(falconSeal, that.falconSeal);
}
@Override
public int hashCode() {
return Objects.hash(roundIdentifier, proposedBlock, blockAccessList, falconSeal);
}
@Override
public String toString() {
return MoreObjects.toStringHelper(this)
.add("roundIdentifier", roundIdentifier)
.add("proposedBlock", proposedBlock)
.add("blockAccessList", blockAccessList)
.add("falconSeal", falconSeal.isPresent() ? "present" : "absent")
.toString();
}
private static Optional<BlockAccessList> readBlockAccessList(final RLPInput rlpInput) {
if (rlpInput.isEndOfCurrentList()) {
// Backward compatibility: pre-26.1.0 messages do not include blockAccessList
return Optional.empty();
}
if (!rlpInput.nextIsNull()) {
return Optional.of(BlockAccessListDecoder.decode(rlpInput));
}
rlpInput.skipNext();
return Optional.empty();
}
}

View File

@ -244,9 +244,18 @@ public class QbftRound {
final List<SignedData<PreparePayload>> prepares) { final List<SignedData<PreparePayload>> prepares) {
final Proposal proposal; final Proposal proposal;
try { try {
// AERE PQ PROPOSAL (2026-08-30): the seal is computed once, here, and rides INSIDE the
// signed payload. With the gate closed - every node today - the seal is empty and the OLD
// overload is taken call for call, so a node that emits nothing new behaves identically to
// one from before this change. Same pattern as PREPARE and commit, deliberately.
final Optional<FalconSeal> proposalSeal = proposalSealFor(block);
proposal = proposal =
messageFactory.createProposal( proposalSeal.isPresent()
getRoundIdentifier(), block, blockAccessList, roundChanges, prepares); ? messageFactory.createProposal(
getRoundIdentifier(), block, blockAccessList, roundChanges, prepares,
proposalSeal)
: messageFactory.createProposal(
getRoundIdentifier(), block, blockAccessList, roundChanges, prepares);
} catch (final SecurityModuleException e) { } catch (final SecurityModuleException e) {
LOG.warn("Failed to create a signed Proposal, waiting for next round.", e); LOG.warn("Failed to create a signed Proposal, waiting for next round.", e);
return; return;
@ -283,16 +292,16 @@ public class QbftRound {
LOG.debug("Sending prepare message. round={}", roundState.getRoundIdentifier()); LOG.debug("Sending prepare message. round={}", roundState.getRoundIdentifier());
try { try {
// AERE PQ PREPARE (2026-08-28), step 2: the seal is computed EXACTLY ONCE here and is // AERE PQ PREPARE (2026-08-28), step 2: the seal is computed EXACTLY ONCE here and is
// trece si exemplarului local si celui de pe fir. Semnaturile Falcon sunt randomizate, deci // handed to both the local copy and the one on the wire. Falcon signatures are randomised,
// doua semnari ale aceluiasi mesaj dau doi octeti diferiti; daca fiecare exemplar si-ar semna // so two signings of the same message give two different byte strings; if each copy signed
// its own, the same validator would produce two valid and DIFFERENT PREPAREs for the same // its own, the same validator would produce two valid and DIFFERENT PREPAREs for the same
// round. The gate is closed on every node today, so this is empty until a decision. // round. The gate is closed on every node today, so this is empty until a decision.
final Optional<FalconSeal> falconSeal = prepareSealFor(block); final Optional<FalconSeal> falconSeal = prepareSealFor(block);
// WITH THE GATE CLOSED the OLD path is taken, call for call. This is not style: the upstream // WITH THE GATE CLOSED the OLD path is taken, call for call. This is not style: the upstream
// tests assert exactly the two-argument call, and more importantly a node that emits nothing new // tests assert exactly the two-argument call, and more importantly a node that emits nothing
// trebuie sa se comporte identic cu unul de dinaintea acestei schimbari - nu doar sa scrie // new must behave identically to one from before this change - not merely write the same
// aceiasi octeti, ci sa treaca prin aceleasi apeluri. Asa, binarul poate sta pe flota fara // bytes, but go through the same calls. That way the binary can sit on the fleet with
// so that nothing changes until a decision is made. Same pattern as commit. // nothing changing until a decision is made. Same pattern as commit.
final Prepare localPrepareMessage = final Prepare localPrepareMessage =
falconSeal.isPresent() falconSeal.isPresent()
? messageFactory.createPrepare(getRoundIdentifier(), block.getHash(), falconSeal) ? messageFactory.createPrepare(getRoundIdentifier(), block.getHash(), falconSeal)
@ -311,11 +320,11 @@ public class QbftRound {
} }
/** /**
* Sigiliul post-cuantic al acestui nod pentru PREPARE-ul blocului dat, sau gol. * This node's post-quantum seal for the PREPARE of the given block, or empty.
* *
* <p>Mesajul semnat are DOMENIUL LUI si contine RUNDA - vezi PqAnchor.prepareMessage si * <p>The signed message has ITS OWN DOMAIN and contains the ROUND - see PqAnchor.prepareMessage
* proiectarea din 2026-08-28. Cu domeniul commitului, un sigiliu de PREPARE dat cinstit ar putea * and the 2026-08-28 design note. Under the commit domain, a PREPARE seal given honestly could
* fi lipit pe un COMMIT falsificat. * be pasted onto a forged COMMIT.
*/ */
private Optional<FalconSeal> prepareSealFor(final QbftBlock block) { private Optional<FalconSeal> prepareSealFor(final QbftBlock block) {
final long blockNumber = block.getHeader().getNumber(); final long blockNumber = block.getHeader().getNumber();
@ -328,6 +337,24 @@ public class QbftRound {
return FalconSealSupport.instance().signPrepare(blockNumber, message); return FalconSealSupport.instance().signPrepare(blockNumber, message);
} }
/**
* This node's post-quantum seal for its own PROPOSAL of the given block, or empty.
*
* <p>Its own domain again - see PqAnchor.proposalMessage. A proposal is an offer, not a vote:
* under the PREPARE or commit domain, a proposal seal given honestly could be pasted onto a
* forged vote and counted.
*/
private Optional<FalconSeal> proposalSealFor(final QbftBlock block) {
final long blockNumber = block.getHeader().getNumber();
final Bytes32 message =
PqAnchor.proposalMessage(
PqAnchorProducer.config().chainId(),
blockNumber,
getRoundIdentifier().getRoundNumber(),
block.getHash().getBytes());
return FalconSealSupport.instance().signProposal(blockNumber, message);
}
/** /**
* Handle prepare message. * Handle prepare message.
* *

View File

@ -0,0 +1,202 @@
/*
* Copyright contributors to Besu.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
* specific language governing permissions and limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
*/
package org.hyperledger.besu.consensus.qbft.core.validation;
import org.hyperledger.besu.consensus.common.bft.FalconSeal;
import org.hyperledger.besu.consensus.common.bft.PqAnchor;
import org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer;
import org.hyperledger.besu.consensus.common.bft.PqSignerRegistry;
import org.hyperledger.besu.datatypes.Address;
import org.hyperledger.besu.datatypes.Hash;
import java.util.Optional;
import org.apache.tuweni.bytes.Bytes32;
/**
* POST-QUANTUM ENFORCEMENT ON THE PROPOSAL. The hot-path step after PREPARE (AERE PQ, 2026-08-30).
*
* <p>From the armed height onwards, a PROPOSAL is not accepted without a valid post-quantum seal
* from its OWN proposer. The structure copies {@link PqProposalEnforcement} line for line on
* purpose: a second rendering of the same idea, written differently, diverges eventually.
*
* <p><b>WHAT THIS LAYER BUYS, said precisely.</b> A proposal is an OFFER, not a vote: the design
* note of 2026-08-28 measures that "prepared" needs a full quorum of PREPAREs, proposer included,
* so an adversary who breaks ECDSA and forges proposals still cannot finalize anything while the
* PREPARE layer is armed. What forged proposals CAN do is start rounds and waste them - steering
* which honest proposals get considered and degrading liveness. This layer closes that: a proposal
* whose proposer cannot produce a Falcon seal does not even open a round on an armed node.
*
* <p><b>WHAT THE SEAL SIGNS.</b> Its own domain, {@code AERE-PQ-PROPOSAL-1}, over (chainId,
* height, ROUND, digest). Under the PREPARE or commit domain, a proposal seal given honestly could
* be pasted onto a forged vote and counted; one changed string in the preimage makes the
* signatures non-transferable in both directions.
*
* <p><b>DISARMED BY DEFAULT.</b> Without the property, {@link #fromSystemConfig()} returns null and
* the validator behaves exactly as upstream. A value that is PRESENT but unreadable REFUSES loudly:
* a node that boots disarmed because of a mistyped character looks exactly like a correctly
* configured one, right up to the day it matters.
*/
public final class PqProposalEnforcement {
/** The height nothing ever reaches: disarmed. */
public static final long DISARMED = Long.MAX_VALUE;
/** The property that arms PROPOSAL enforcement. */
public static final String PROPERTY_FORK_BLOCK = "aere.pq.proposalPq.forkBlock";
/** The equivalent environment variable. */
public static final String ENV_FORK_BLOCK = "AERE_PQ_PROPOSALPQ_FORKBLOCK";
private final long armedFromBlock;
private final PqSignerRegistry registry;
private final long chainId;
/**
* @param armedFromBlock first height (inclusive) at which enforcement applies; {@link #DISARMED}
* for today's behaviour
* @param registry the signer registry, injected, never a singleton
* @param chainId the chain that goes into the signed message
*/
public PqProposalEnforcement(
final long armedFromBlock, final PqSignerRegistry registry, final long chainId) {
this.armedFromBlock = armedFromBlock;
this.registry = registry;
this.chainId = chainId;
}
/**
* The same enforcement, with the chain id taken from the anchor configuration.
*
* <p>THE CHAIN ID IS AN ARGUMENT, NOT A GLOBAL, and its own test caught that: the first version
* read it from {@code PqAnchorProducer.config()} in the middle of a consensus decision, so the
* test signed over 2800 while the enforcement verified over whatever the process configuration
* happened to be. A consensus decision that depends on global state cannot be tested honestly,
* and cannot be read either. The factories below fetch the value once, at construction, where it
* is visible.
*
* @param armedFromBlock first height at which enforcement applies
* @param registry the signer registry
*/
public PqProposalEnforcement(final long armedFromBlock, final PqSignerRegistry registry) {
this(armedFromBlock, registry, PqAnchorProducer.config().chainId());
}
/**
* The configured enforcement, read FRESH on every call.
*
* @return the armed enforcement, or null when the property is set nowhere
* @throws IllegalStateException AERE-PQC-PROPOSAL-ENF-01 when the value is present but is not a
* non-negative decimal height
*/
public static PqProposalEnforcement fromSystemConfig() {
String raw = System.getProperty(PROPERTY_FORK_BLOCK);
if (raw == null) {
raw = System.getenv(ENV_FORK_BLOCK);
}
if (raw == null || raw.isBlank()) {
return null;
}
final long armedFrom;
try {
armedFrom = Long.parseLong(raw.trim());
if (armedFrom < 0) {
throw new NumberFormatException("negative");
}
} catch (final NumberFormatException e) {
throw new IllegalStateException(
"AERE-PQC-PROPOSAL-ENF-01: "
+ PROPERTY_FORK_BLOCK
+ " is set but not a non-negative block height: '"
+ raw
+ "'. A mistyped value must refuse, never silently disarm.");
}
return new PqProposalEnforcement(armedFrom, PqSignerRegistry.falconSealSupport());
}
/** Whether enforcement is active at this height. */
public boolean armedAt(final long height) {
return height >= armedFromBlock;
}
/**
* Decides whether a PROPOSAL may be accepted.
*
* @param height the height the PROPOSAL targets (the round's sequence number)
* @param round the PROPOSAL's round; it is part of the signed message
* @param author the RECOVERED author of the signed message (from its ECDSA signature)
* @param digest the digest of the proposed block
* @param seal the post-quantum seal carried by the payload, if any
* @return empty when the PROPOSAL is acceptable; otherwise the refusal, with names and numbers
*/
public Optional<String> refusal(
final long height,
final int round,
final Address author,
final Hash digest,
final Optional<FalconSeal> seal) {
if (!armedAt(height)) {
return Optional.empty();
}
if (seal == null || seal.isEmpty()) {
return Optional.of(
"AERE FULL-PQ: proposal at height " + height + " round " + round
+ " carries NO post-quantum seal and enforcement is armed from " + armedFromBlock
+ " - the proposal is refused");
}
final FalconSeal fs = seal.get();
final Address bound;
try {
bound = registry.addressForIndexAtOwnHead(height, fs.getValidatorIndex());
} catch (final RuntimeException e) {
return Optional.of(
"AERE FULL-PQ: registry refused index " + fs.getValidatorIndex() + " at height "
+ height + ": " + e.getMessage());
}
if (bound == null || !bound.equals(author)) {
return Optional.of(
"AERE FULL-PQ: proposal seal index " + fs.getValidatorIndex() + " is bound to " + bound
+ " but the proposal was authored by " + author
+ " - a seal cannot vouch for someone else's proposal");
}
final Bytes32 message;
try {
message = PqAnchor.proposalMessage(chainId, height, round, digest.getBytes());
} catch (final RuntimeException e) {
// A message we cannot build means we cannot judge, and "cannot judge" must never be a pass:
// that would be exactly the silent disarming this file exists to refuse.
return Optional.of(
"AERE FULL-PQ: could not build the proposal message at height " + height + " round "
+ round + ": " + e.getMessage());
}
final boolean valid;
try {
valid = registry.verifyAtOwnHead(height, fs.getValidatorIndex(), message, fs.getSignature());
} catch (final RuntimeException e) {
return Optional.of(
"AERE FULL-PQ: verification threw for index " + fs.getValidatorIndex() + " at height "
+ height + ": " + e.getMessage());
}
if (!valid) {
return Optional.of(
"AERE FULL-PQ: post-quantum seal of index " + fs.getValidatorIndex()
+ " does NOT verify over the proposal message at height " + height + " round " + round
+ " - the proposal is refused");
}
return Optional.empty();
}
}

View File

@ -0,0 +1,182 @@
/*
* Copyright contributors to Hyperledger Besu.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
* specific language governing permissions and limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
*/
package org.hyperledger.besu.consensus.qbft.core.validation;
import static com.google.common.base.Preconditions.checkState;
import org.hyperledger.besu.consensus.common.bft.ConsensusRoundIdentifier;
import org.hyperledger.besu.consensus.common.bft.payload.SignedData;
import org.hyperledger.besu.consensus.qbft.core.payload.ProposalPayload;
import org.hyperledger.besu.consensus.qbft.core.types.QbftBlock;
import org.hyperledger.besu.consensus.qbft.core.types.QbftBlockValidator;
import org.hyperledger.besu.datatypes.Address;
import org.hyperledger.besu.ethereum.mainnet.block.access.list.BlockAccessList;
import java.util.Optional;
import com.google.common.annotations.VisibleForTesting;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The Proposal payload validator.
*
* <p>AERE (2026-08-30): an OPTIONAL post-quantum enforcement hook, gated on height (see {@link
* PqProposalEnforcement}). When no enforcement is supplied, behaviour is byte for byte the upstream
* one - and that is the configuration of every node today. Same pattern as the PREPARE and commit
* validators, deliberately: a second rendering of the same idea, written differently, diverges
* eventually.
*
* <p>The 3-argument constructor - the one production code calls - wires the enforcement itself
* from the system configuration, exactly as {@link PrepareValidator} does. That self-wiring is the
* security property: an enforcement that had to be passed in explicitly could be dropped by any
* refactor without a single test failing.
*/
public class ProposalPayloadValidator {
private static final String ERROR_PREFIX = "Invalid Proposal Payload";
private static final Logger LOG = LoggerFactory.getLogger(ProposalPayloadValidator.class);
private final Address expectedProposer;
private final ConsensusRoundIdentifier targetRound;
private final QbftBlockValidator blockValidator;
private final PqProposalEnforcement pqEnforcement;
/**
* Instantiates a new Proposal payload validator, with the post-quantum enforcement wired from
* the system configuration. This is the constructor production code calls.
*
* @param expectedProposer the expected proposer
* @param targetRound the target round
* @param blockValidator the block validator
*/
@VisibleForTesting
public ProposalPayloadValidator(
final Address expectedProposer,
final ConsensusRoundIdentifier targetRound,
final QbftBlockValidator blockValidator) {
this(expectedProposer, targetRound, blockValidator, PqProposalEnforcement.fromSystemConfig());
}
/**
* Instantiates a new Proposal payload validator with an explicit post-quantum enforcement. Test
* seam; production goes through the 3-argument constructor above.
*
* @param expectedProposer the expected proposer
* @param targetRound the target round
* @param blockValidator the block validator
* @param pqEnforcement the post-quantum enforcement, or null for upstream behaviour
*/
@VisibleForTesting
public ProposalPayloadValidator(
final Address expectedProposer,
final ConsensusRoundIdentifier targetRound,
final QbftBlockValidator blockValidator,
final PqProposalEnforcement pqEnforcement) {
this.expectedProposer = expectedProposer;
this.targetRound = targetRound;
this.blockValidator = blockValidator;
this.pqEnforcement = pqEnforcement;
}
/**
* Validate.
*
* @param signedPayload the signed Proposal payload
* @return the boolean
*/
public boolean validate(final SignedData<ProposalPayload> signedPayload) {
return validate(signedPayload, true);
}
/**
* Validate without block validation.
*
* @param signedPayload the signed Proposal payload
* @return the boolean
*/
public boolean validateWithoutBlockValidation(final SignedData<ProposalPayload> signedPayload) {
return validate(signedPayload, false);
}
/**
* Validate with optional block validation.
*
* @param signedPayload the signed Proposal payload
* @param validateBlock whether to validate the block
* @return the boolean
*/
private boolean validate(
final SignedData<ProposalPayload> signedPayload, final boolean validateBlock) {
if (!signedPayload.getAuthor().equals(expectedProposer)) {
LOG.info("{}: proposal created by non-proposer", ERROR_PREFIX);
return false;
}
final ProposalPayload payload = signedPayload.getPayload();
if (!payload.getRoundIdentifier().equals(targetRound)) {
LOG.info("{}: proposal is not for expected round", ERROR_PREFIX);
return false;
}
final QbftBlock block = payload.getProposedBlock();
if (validateBlock && !validateBlock(block, payload.getBlockAccessList())) {
return false;
}
if (block.getHeader().getNumber() != payload.getRoundIdentifier().getSequenceNumber()) {
LOG.info("{}: block number does not match sequence number", ERROR_PREFIX);
return false;
}
// AERE full-PQ: from the armed height, a proposal is not accepted without a valid post-quantum
// seal from its OWN proposer. Below the height, and on every node without the property, this
// is a null check and nothing more - the upstream path, call for call.
if (pqEnforcement != null) {
final Optional<String> refusal =
pqEnforcement.refusal(
payload.getRoundIdentifier().getSequenceNumber(),
payload.getRoundIdentifier().getRoundNumber(),
signedPayload.getAuthor(),
block.getHash(),
payload.getFalconSeal());
if (refusal.isPresent()) {
LOG.info("{}: {}", ERROR_PREFIX, refusal.get());
return false;
}
}
return true;
}
private boolean validateBlock(
final QbftBlock block, final Optional<BlockAccessList> blockAccessList) {
checkState(blockValidator != null, "block validation not possible, no block validator.");
final var validationResult = blockValidator.validateBlock(block, blockAccessList);
if (!validationResult.success()) {
LOG.info(
"{}: block did not pass validation. Reason {}",
ERROR_PREFIX,
validationResult.errorMessage());
return false;
}
return true;
}
}

View File

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

View File

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

View File

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