The round-change now carries an optional post-quantum seal, disarmed by default
The last hot-path QBFT message under the same pattern as prepare and proposal: its own domain AERE-PQ-ROUNDCHANGE-1 over (chainId, height, targetRound, prepared metadata - so a bare 'move on' seal cannot be replayed as 'move on and re-propose THIS block'), a separate emission gate (aere.pq.roundChangePq.attachBlock), a separate enforcement height (aere.pq.roundChangePq.forkBlock) self-wired into the payload validator so it also judges round-changes inside proposal justifications, and byte-for-byte upstream encoding when no seal is present. The seal is signed once and the same object goes on the wire; Falcon signatures are randomised, so a re-signing would put a different message on the wire than the one handled locally.
This commit is contained in:
parent
a9af2de488
commit
7242a2c388
@ -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
|
||||||
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
|
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
|
||||||
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
|
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
|
||||||
@ -82,14 +82,16 @@ e0bc7a465dbb2dd7efa5f1830211a1a45c6554c300b2088dd70bb9677b0ac217 consensus/comm
|
|||||||
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
|
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
|
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
|
||||||
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
|
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
|
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
|
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
|
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
|
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
|
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
|
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
|
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
|
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
|
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
|
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
|
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
|
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
|
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
|
||||||
@ -112,9 +118,11 @@ c0853ed97c53d54951e25ad6d0b70c0299dedd859cc7c44da64e3751d0e0de33 consensus/qbft
|
|||||||
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
|
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
|
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
|
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
|
||||||
|
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
|
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
|
||||||
47fbdd8639c464bf19bac3a0ba540af4d99f79e9df02c2ad586a19a2aebd037d consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/adaptor/QbftBlockCreatorAdaptor.java
|
47fbdd8639c464bf19bac3a0ba540af4d99f79e9df02c2ad586a19a2aebd037d consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/adaptor/QbftBlockCreatorAdaptor.java
|
||||||
|
|||||||
@ -144,6 +144,10 @@ public final class FalconSealSupport {
|
|||||||
private final java.util.concurrent.atomic.AtomicLong proposalsSealed =
|
private final java.util.concurrent.atomic.AtomicLong proposalsSealed =
|
||||||
new java.util.concurrent.atomic.AtomicLong();
|
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 boolean signingEnabled;
|
||||||
private final int localIndex;
|
private final int localIndex;
|
||||||
private final FalconPrivateKeyParameters localPrivateKey;
|
private final FalconPrivateKeyParameters localPrivateKey;
|
||||||
@ -3681,6 +3685,105 @@ public final class FalconSealSupport {
|
|||||||
return proposalsSealed.get();
|
return proposalsSealed.get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The property that opens the ROUND-CHANGE attachment gate: from this height on, this node's own
|
||||||
|
* round-changes carry a Falcon seal. Emission only - enforcement is {@code
|
||||||
|
* aere.pq.roundChangePq.forkBlock}, deliberately separate so the fleet can emit long before any
|
||||||
|
* node refuses, exactly as at PREPARE and PROPOSAL.
|
||||||
|
*/
|
||||||
|
public static final String ROUNDCHANGE_ATTACH_PROPERTY = "aere.pq.roundChangePq.attachBlock";
|
||||||
|
|
||||||
|
/** Environment fallback for {@link #ROUNDCHANGE_ATTACH_PROPERTY}. */
|
||||||
|
public static final String ROUNDCHANGE_ATTACH_ENV = "AERE_PQ_ROUNDCHANGEPQ_ATTACHBLOCK";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The configured ROUND-CHANGE attachment height, read fresh on every call.
|
||||||
|
*
|
||||||
|
* <p>Absent = {@link Long#MAX_VALUE}, i.e. never. A value that is PRESENT but unreadable REFUSES
|
||||||
|
* loudly instead of disarming - the anchor loader's lesson, once per gate, every gate.
|
||||||
|
*
|
||||||
|
* @return the height, or Long.MAX_VALUE when unset
|
||||||
|
*/
|
||||||
|
public static long roundChangeAttachBlock() {
|
||||||
|
final String raw = resolve(ROUNDCHANGE_ATTACH_PROPERTY, ROUNDCHANGE_ATTACH_ENV);
|
||||||
|
if (raw == null || raw.isBlank()) {
|
||||||
|
return Long.MAX_VALUE;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final long v = Long.parseLong(raw.trim());
|
||||||
|
if (v < 0) {
|
||||||
|
throw new NumberFormatException("negative");
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
} catch (final NumberFormatException e) {
|
||||||
|
throw new ActivationConfigException(
|
||||||
|
ActivationConfigException.Kind.SYNTAX,
|
||||||
|
"AERE-PQC-ROUNDCHANGE-CONF-01",
|
||||||
|
"AERE PQ ROUNDCHANGE: "
|
||||||
|
+ ROUNDCHANGE_ATTACH_PROPERTY
|
||||||
|
+ " is set to '"
|
||||||
|
+ raw
|
||||||
|
+ "', which is not a non-negative block height. A node must REFUSE to start rather "
|
||||||
|
+ "than silently run with ROUND-CHANGE attachment disarmed: a disarmed node looks "
|
||||||
|
+ "exactly like a correctly configured one until the day it matters.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign this node's own ROUND-CHANGE, when the ROUND-CHANGE attachment gate is open at this
|
||||||
|
* height.
|
||||||
|
*
|
||||||
|
* <p>Same three requirements as {@link #signPrepare} and {@link #signProposal}, for the same
|
||||||
|
* three reasons: a key, the commit-layer coverage conditions, and its own height. NEVER throws
|
||||||
|
* except for the strict configuration case: a signing failure is a log line and an empty value,
|
||||||
|
* because the ECDSA path must not be disturbed.
|
||||||
|
*
|
||||||
|
* @param blockNumber the height the round-change targets
|
||||||
|
* @param message the domain-separated ROUND-CHANGE message (see PqAnchor.roundChangeMessage)
|
||||||
|
* @return the seal, or empty when any gate is shut
|
||||||
|
*/
|
||||||
|
public Optional<FalconSeal> signRoundChange(final long blockNumber, final Bytes32 message) {
|
||||||
|
if (!signingEnabled) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
if (!attachmentArmed(blockNumber)) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
if (blockNumber < roundChangeAttachBlock()) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
final java.util.Optional<byte[]> sig =
|
||||||
|
((FalconSealScheme) SealSchemes.FALCON_512).signWithParams(localPrivateKey, message.toArray());
|
||||||
|
if (sig.isEmpty()) {
|
||||||
|
LOG.warn("AERE PQ ROUNDCHANGE: Falcon signing failed (ECDSA path unaffected)");
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
final long n = roundChangesSealed.incrementAndGet();
|
||||||
|
// Round-changes only exist when rounds FAIL, so on a healthy chain this counter barely moves;
|
||||||
|
// it still steps so a testnet that forces round-changes has a number to count.
|
||||||
|
if (n == 1L) {
|
||||||
|
LOG.info(
|
||||||
|
"AERE PQ ROUNDCHANGE: this node EMITTED its first post-quantum seal on a ROUND-CHANGE, "
|
||||||
|
+ "at height {} (gate {}={}). From here on its round-changes carry a seal.",
|
||||||
|
blockNumber,
|
||||||
|
ROUNDCHANGE_ATTACH_PROPERTY,
|
||||||
|
roundChangeAttachBlock());
|
||||||
|
} else if (n % 100L == 0L) {
|
||||||
|
LOG.info("AERE PQ ROUNDCHANGE: {} seals emitted on ROUND-CHANGEs since startup.", n);
|
||||||
|
}
|
||||||
|
return Optional.of(new FalconSeal(localIndex, Bytes.wrap(sig.get())));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many seals this node has emitted on its own ROUND-CHANGEs since startup. Exists so the
|
||||||
|
* coverage step can be measured, exactly as at PREPARE and PROPOSAL.
|
||||||
|
*
|
||||||
|
* @return the count
|
||||||
|
*/
|
||||||
|
public long roundChangesSealed() {
|
||||||
|
return roundChangesSealed.get();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verify a Falcon seal against the registry public key for its validator index. Never throws.
|
* Verify a Falcon seal against the registry public key for its validator index. Never throws.
|
||||||
*
|
*
|
||||||
|
|||||||
@ -128,6 +128,21 @@ public final class PqAnchor {
|
|||||||
public static final Bytes PROPOSAL_DOMAIN_BYTES =
|
public static final Bytes PROPOSAL_DOMAIN_BYTES =
|
||||||
Bytes.wrap(PROPOSAL_DOMAIN.getBytes(StandardCharsets.US_ASCII));
|
Bytes.wrap(PROPOSAL_DOMAIN.getBytes(StandardCharsets.US_ASCII));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ROUND-CHANGE domain label. AERE PQ (2026-08-31), the last hot-path message.
|
||||||
|
*
|
||||||
|
* <p>SEPARATE FROM ALL THREE OTHERS, for the same non-transferability reason. A ROUND-CHANGE is
|
||||||
|
* the message that STEERS rounds: a quorum of them opens a new round, and one that claims a
|
||||||
|
* prepared block decides WHICH block gets re-proposed. A seal given honestly over a vote or a
|
||||||
|
* proposal must not be pasteable onto a round-change, and a round-change seal must not count as
|
||||||
|
* either. One changed string in the preimage enforces that cryptographically.
|
||||||
|
*/
|
||||||
|
public static final String ROUNDCHANGE_DOMAIN = "AERE-PQ-ROUNDCHANGE-1";
|
||||||
|
|
||||||
|
/** The round-change domain label as raw bytes. */
|
||||||
|
public static final Bytes ROUNDCHANGE_DOMAIN_BYTES =
|
||||||
|
Bytes.wrap(ROUNDCHANGE_DOMAIN.getBytes(StandardCharsets.US_ASCII));
|
||||||
|
|
||||||
/** Orders Falcon seals by their registry index, ascending. */
|
/** 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);
|
||||||
@ -328,6 +343,87 @@ public final class PqAnchor {
|
|||||||
return Hash.keccak256(out.encoded());
|
return Hash.keccak256(out.encoded());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The 32-byte message a validator's post-quantum ROUND-CHANGE seal signs, for a round-change
|
||||||
|
* that carries NO prepared-round metadata.
|
||||||
|
*
|
||||||
|
* <p>The preimage writes an explicit presence flag of 0 and empty placeholders, so a bare
|
||||||
|
* round-change and one prepared at round 0 can never collide: the flag, not the emptiness of a
|
||||||
|
* field, is what says whether metadata exists.
|
||||||
|
*
|
||||||
|
* @param chainId the chain id
|
||||||
|
* @param blockNumber the height the round-change targets (its sequence number)
|
||||||
|
* @param targetRound the round the sender wants to move to
|
||||||
|
* @return the 32-byte message to sign
|
||||||
|
*/
|
||||||
|
public static Bytes32 roundChangeMessage(
|
||||||
|
final long chainId, final long blockNumber, final int targetRound) {
|
||||||
|
return roundChangePreimage(chainId, blockNumber, targetRound, false, 0, Bytes.EMPTY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The 32-byte message a validator's post-quantum ROUND-CHANGE seal signs, for a round-change
|
||||||
|
* that CLAIMS a prepared block.
|
||||||
|
*
|
||||||
|
* <p>The prepared metadata is IN the preimage on purpose: a round-change that claims a prepared
|
||||||
|
* block decides which block gets re-proposed, so a seal from a bare round-change pasted onto one
|
||||||
|
* with metadata (or onto one with different metadata) must not verify. Same shape as {@link
|
||||||
|
* #proposalMessage}, its own domain.
|
||||||
|
*
|
||||||
|
* @param chainId the chain id
|
||||||
|
* @param blockNumber the height the round-change targets (its sequence number)
|
||||||
|
* @param targetRound the round the sender wants to move to
|
||||||
|
* @param preparedRound the round the claimed prepared block was prepared in
|
||||||
|
* @param preparedDigest the 32-byte digest of the claimed prepared block
|
||||||
|
* @return the 32-byte message to sign
|
||||||
|
*/
|
||||||
|
public static Bytes32 roundChangeMessage(
|
||||||
|
final long chainId,
|
||||||
|
final long blockNumber,
|
||||||
|
final int targetRound,
|
||||||
|
final int preparedRound,
|
||||||
|
final Bytes preparedDigest) {
|
||||||
|
if (preparedRound < 0) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"AERE PQ ROUNDCHANGE: preparedRound must not be negative (got " + preparedRound + ")");
|
||||||
|
}
|
||||||
|
if (preparedDigest == null || preparedDigest.size() != 32) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"AERE PQ ROUNDCHANGE: preparedDigest must be 32 bytes (got "
|
||||||
|
+ (preparedDigest == null ? "null" : preparedDigest.size() + " bytes")
|
||||||
|
+ ")");
|
||||||
|
}
|
||||||
|
return roundChangePreimage(chainId, blockNumber, targetRound, true, preparedRound, preparedDigest);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Bytes32 roundChangePreimage(
|
||||||
|
final long chainId,
|
||||||
|
final long blockNumber,
|
||||||
|
final int targetRound,
|
||||||
|
final boolean hasPrepared,
|
||||||
|
final int preparedRound,
|
||||||
|
final Bytes preparedDigest) {
|
||||||
|
if (blockNumber < 0) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"AERE PQ ROUNDCHANGE: blockNumber must not be negative (got " + blockNumber + ")");
|
||||||
|
}
|
||||||
|
if (targetRound < 0) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"AERE PQ ROUNDCHANGE: targetRound must not be negative (got " + targetRound + ")");
|
||||||
|
}
|
||||||
|
final BytesValueRLPOutput out = new BytesValueRLPOutput();
|
||||||
|
out.startList();
|
||||||
|
out.writeBytes(ROUNDCHANGE_DOMAIN_BYTES);
|
||||||
|
out.writeLongScalar(chainId);
|
||||||
|
out.writeLongScalar(blockNumber);
|
||||||
|
out.writeLongScalar(targetRound);
|
||||||
|
out.writeLongScalar(hasPrepared ? 1 : 0);
|
||||||
|
out.writeLongScalar(preparedRound);
|
||||||
|
out.writeBytes(preparedDigest);
|
||||||
|
out.endList();
|
||||||
|
return Hash.keccak256(out.encoded());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the certificate's validator indices are STRICTLY increasing.
|
* Whether the certificate's validator indices are STRICTLY increasing.
|
||||||
*
|
*
|
||||||
|
|||||||
@ -0,0 +1,108 @@
|
|||||||
|
/*
|
||||||
|
* Copyright contributors to Besu.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
|
||||||
|
* the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
|
||||||
|
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations under the License.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
package org.hyperledger.besu.consensus.common.bft;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
import org.apache.tuweni.bytes.Bytes32;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE GATE that emits a seal on the ROUND-CHANGE (AERE PQ, 2026-08-31). Twin of the PREPARE and
|
||||||
|
* PROPOSAL gates, with the same shape on purpose.
|
||||||
|
*
|
||||||
|
* <p>What is measured here is the CONFIGURATION SURFACE, which is exactly the part that gets typed
|
||||||
|
* by hand and therefore mistyped: absent means never, a good value means from that height onwards,
|
||||||
|
* and a MISTYPED value refuses loudly instead of booting the node disarmed. The lesson paid for in
|
||||||
|
* the anchor loader is that a stray character must never disarm silently, because then nobody finds
|
||||||
|
* out.
|
||||||
|
*
|
||||||
|
* <p>What is NOT measured here, and it is said plainly: that an ARMED node actually produces a
|
||||||
|
* seal. That needs a Falcon key, a registry bound to addresses, and a FAILED round to provoke the
|
||||||
|
* message at all, which means a network; it is measured at the coverage step, on a testnet. What is
|
||||||
|
* proven here is that the gate is closed by default and cannot be opened by accident.
|
||||||
|
*/
|
||||||
|
class RoundChangePqAttachGateTest {
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void clearTheProperty() {
|
||||||
|
System.clearProperty(FalconSealSupport.ROUNDCHANGE_ATTACH_PROPERTY);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void withoutThePropertyTheGateIsClosedForever() {
|
||||||
|
assertThat(FalconSealSupport.roundChangeAttachBlock()).isEqualTo(Long.MAX_VALUE);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aGoodValueIsReadAsGiven() {
|
||||||
|
System.setProperty(FalconSealSupport.ROUNDCHANGE_ATTACH_PROPERTY, "16500000");
|
||||||
|
assertThat(FalconSealSupport.roundChangeAttachBlock()).isEqualTo(16_500_000L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void zeroIsALEGALValue() {
|
||||||
|
// A threshold of zero means "from genesis", and that is a legitimate configuration on a
|
||||||
|
// testnet. Treated as "unset", a correctly configured testnet would run disarmed in silence.
|
||||||
|
System.setProperty(FalconSealSupport.ROUNDCHANGE_ATTACH_PROPERTY, "0");
|
||||||
|
assertThat(FalconSealSupport.roundChangeAttachBlock()).isZero();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aMISTYPEDValueRefusesLoudly() {
|
||||||
|
for (final String bad : new String[] {"nu-e-numar", "16_500_000", "1e6", "-1", " "}) {
|
||||||
|
System.setProperty(FalconSealSupport.ROUNDCHANGE_ATTACH_PROPERTY, bad);
|
||||||
|
if (bad.isBlank()) {
|
||||||
|
// whitespace is "unset", not a mistyped value: an empty field in a configuration file
|
||||||
|
// must not stop a node
|
||||||
|
assertThat(FalconSealSupport.roundChangeAttachBlock()).isEqualTo(Long.MAX_VALUE);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
assertThatThrownBy(FalconSealSupport::roundChangeAttachBlock)
|
||||||
|
.as("the value '%s'", bad)
|
||||||
|
.isInstanceOf(FalconSealSupport.ActivationConfigException.class)
|
||||||
|
.hasMessageContaining("AERE-PQC-ROUNDCHANGE-CONF-01");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void withNoKeyNothingIsSignedEvenWithTheGateOpen() {
|
||||||
|
// The gate is open from genesis and still nothing comes out: the node has no Falcon key. That
|
||||||
|
// is precisely the condition that makes the binary safe to roll onto the fleet before any
|
||||||
|
// decision is taken.
|
||||||
|
System.setProperty(FalconSealSupport.ROUNDCHANGE_ATTACH_PROPERTY, "0");
|
||||||
|
assertThat(FalconSealSupport.instance().signRoundChange(1L, Bytes32.ZERO)).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void theROUNDCHANGEGateIsNoneOfItsThreeSiblings() {
|
||||||
|
// Four layers, four switches. If any two shared one, the day one is turned on would become a
|
||||||
|
// flag day for the other - and commit emission is already on since block 13,889,296 on 2800.
|
||||||
|
assertThat(FalconSealSupport.ROUNDCHANGE_ATTACH_PROPERTY)
|
||||||
|
.isNotEqualTo("aere.falcon.attachBlock");
|
||||||
|
assertThat(FalconSealSupport.ROUNDCHANGE_ATTACH_PROPERTY)
|
||||||
|
.isNotEqualTo(FalconSealSupport.PREPARE_ATTACH_PROPERTY);
|
||||||
|
assertThat(FalconSealSupport.ROUNDCHANGE_ATTACH_PROPERTY)
|
||||||
|
.isNotEqualTo(FalconSealSupport.PROPOSAL_ATTACH_PROPERTY);
|
||||||
|
System.setProperty(FalconSealSupport.ROUNDCHANGE_ATTACH_PROPERTY, "16500000");
|
||||||
|
assertThat(FalconSealSupport.roundChangeAttachBlock()).isEqualTo(16_500_000L);
|
||||||
|
// the commit, PREPARE and PROPOSAL properties stay untouched by the ROUND-CHANGE one
|
||||||
|
assertThat(System.getProperty("aere.falcon.attachBlock")).isNull();
|
||||||
|
assertThat(System.getProperty(FalconSealSupport.PREPARE_ATTACH_PROPERTY)).isNull();
|
||||||
|
assertThat(System.getProperty(FalconSealSupport.PROPOSAL_ATTACH_PROPERTY)).isNull();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -244,4 +244,18 @@ public class QbftMessageTransmitter {
|
|||||||
LOG.warn("Failed to generate signature for RoundChange (not sent): {} ", e.getMessage());
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -226,6 +226,25 @@ public class MessageFactory {
|
|||||||
public RoundChange createRoundChange(
|
public RoundChange createRoundChange(
|
||||||
final ConsensusRoundIdentifier roundIdentifier,
|
final ConsensusRoundIdentifier roundIdentifier,
|
||||||
final Optional<PreparedCertificate> preparedRoundData) {
|
final Optional<PreparedCertificate> preparedRoundData) {
|
||||||
|
return createRoundChange(roundIdentifier, preparedRoundData, Optional.empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create round change carrying the author's post-quantum seal. AERE PQ (2026-08-31): the sealed
|
||||||
|
* twin of the method above, exactly as at PREPARE and PROPOSAL. With the gate shut every call of
|
||||||
|
* today goes through the seal-less form, which encodes byte for byte as upstream. The author's
|
||||||
|
* ECDSA signature covers the WHOLE payload, seal included, which binds the claimed index to the
|
||||||
|
* identity of the sender.
|
||||||
|
*
|
||||||
|
* @param roundIdentifier the round identifier
|
||||||
|
* @param preparedRoundData the prepared round data
|
||||||
|
* @param falconSeal the author's post-quantum seal, or empty
|
||||||
|
* @return the round change
|
||||||
|
*/
|
||||||
|
public RoundChange createRoundChange(
|
||||||
|
final ConsensusRoundIdentifier roundIdentifier,
|
||||||
|
final Optional<PreparedCertificate> preparedRoundData,
|
||||||
|
final Optional<FalconSeal> falconSeal) {
|
||||||
|
|
||||||
final RoundChangePayload payload;
|
final RoundChangePayload payload;
|
||||||
if (preparedRoundData.isPresent()) {
|
if (preparedRoundData.isPresent()) {
|
||||||
@ -236,7 +255,8 @@ public class MessageFactory {
|
|||||||
roundIdentifier,
|
roundIdentifier,
|
||||||
Optional.of(
|
Optional.of(
|
||||||
new PreparedRoundMetadata(
|
new PreparedRoundMetadata(
|
||||||
preparedBlock.getHash(), preparedRoundData.get().getRound())));
|
preparedBlock.getHash(), preparedRoundData.get().getRound())),
|
||||||
|
falconSeal);
|
||||||
|
|
||||||
return new RoundChange(
|
return new RoundChange(
|
||||||
createSignedMessage(payload),
|
createSignedMessage(payload),
|
||||||
@ -246,7 +266,7 @@ public class MessageFactory {
|
|||||||
preparedRoundData.get().getPrepares());
|
preparedRoundData.get().getPrepares());
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
payload = new RoundChangePayload(roundIdentifier, Optional.empty());
|
payload = new RoundChangePayload(roundIdentifier, Optional.empty(), falconSeal);
|
||||||
return new RoundChange(
|
return new RoundChange(
|
||||||
createSignedMessage(payload),
|
createSignedMessage(payload),
|
||||||
Optional.empty(),
|
Optional.empty(),
|
||||||
|
|||||||
@ -0,0 +1,211 @@
|
|||||||
|
/*
|
||||||
|
* Copyright ConsenSys AG.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
|
||||||
|
* the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
|
||||||
|
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations under the License.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
package org.hyperledger.besu.consensus.qbft.core.payload;
|
||||||
|
|
||||||
|
import org.hyperledger.besu.consensus.common.bft.ConsensusRoundIdentifier;
|
||||||
|
import org.hyperledger.besu.consensus.common.bft.FalconSeal;
|
||||||
|
import org.hyperledger.besu.consensus.qbft.core.messagedata.QbftV1;
|
||||||
|
import org.hyperledger.besu.ethereum.rlp.RLPException;
|
||||||
|
import org.hyperledger.besu.ethereum.rlp.RLPInput;
|
||||||
|
import org.hyperledger.besu.ethereum.rlp.RLPOutput;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import com.google.common.base.MoreObjects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Round change payload.
|
||||||
|
*
|
||||||
|
* <p>AERE PQ (2026-08-31), the LAST hot-path message: a ROUND-CHANGE MAY carry an OPTIONAL
|
||||||
|
* Falcon-512 seal from its author, appended after the prepared-metadata list, exactly as {@code
|
||||||
|
* ProposalPayload} appends its seal. A round-change without a seal encodes <b>byte for byte as
|
||||||
|
* upstream</b>, which is the property that lets the binary be rolled onto a live fleet without a
|
||||||
|
* flag day.
|
||||||
|
*
|
||||||
|
* <p><b>NOTHING EMITS SUCH A ROUND-CHANGE YET.</b> This file only makes the wire capable of
|
||||||
|
* carrying one and of refusing a malformed one. Emission has its own gate
|
||||||
|
* ({@code aere.pq.roundChangePq.attachBlock}); enforcement has its own height
|
||||||
|
* ({@code aere.pq.roundChangePq.forkBlock}); both are absent on every node today, and absent means
|
||||||
|
* never.
|
||||||
|
*
|
||||||
|
* <p><b>What the seal signs is NOT this file's business:</b> its own domain
|
||||||
|
* {@code AERE-PQ-ROUNDCHANGE-1} over (chainId, height, targetRound, prepared metadata) - see
|
||||||
|
* {@code PqAnchor.roundChangeMessage}. A round-change STEERS rounds: a quorum of them opens a new
|
||||||
|
* round, and one claiming a prepared block decides which block gets re-proposed. Its seal must be
|
||||||
|
* transferable neither onto a vote nor onto a proposal, nor between a bare round-change and one
|
||||||
|
* with metadata.
|
||||||
|
*
|
||||||
|
* <p><b>Why the trailing element is parsed strictly:</b> the payload is AUTHENTICATED - the author
|
||||||
|
* is recovered from the signature over the encoded payload. A decoder that silently ignored an
|
||||||
|
* unknown trailing element would re-encode without it, so two different byte strings would
|
||||||
|
* authenticate to the same author. When a fourth element is present it must be exactly a seal, and
|
||||||
|
* it must be the last element; otherwise the message is refused. Messages of the upstream shape
|
||||||
|
* (three elements) are read exactly as upstream reads them.
|
||||||
|
*/
|
||||||
|
public class RoundChangePayload extends QbftPayload {
|
||||||
|
private static final int TYPE = QbftV1.ROUND_CHANGE;
|
||||||
|
private final ConsensusRoundIdentifier roundChangeIdentifier;
|
||||||
|
private final Optional<PreparedRoundMetadata> preparedRoundMetadata;
|
||||||
|
private final Optional<FalconSeal> falconSeal;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instantiates a new Round change payload carrying an optional post-quantum seal of its author.
|
||||||
|
*
|
||||||
|
* @param roundChangeIdentifier the round change identifier
|
||||||
|
* @param preparedRoundMetadata the prepared round metadata
|
||||||
|
* @param falconSeal the author's post-quantum seal, or empty
|
||||||
|
*/
|
||||||
|
public RoundChangePayload(
|
||||||
|
final ConsensusRoundIdentifier roundChangeIdentifier,
|
||||||
|
final Optional<PreparedRoundMetadata> preparedRoundMetadata,
|
||||||
|
final Optional<FalconSeal> falconSeal) {
|
||||||
|
this.roundChangeIdentifier = roundChangeIdentifier;
|
||||||
|
this.preparedRoundMetadata = preparedRoundMetadata;
|
||||||
|
this.falconSeal = falconSeal == null ? Optional.empty() : falconSeal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instantiates a new Round change payload, without a post-quantum seal. Encodes byte-for-byte as
|
||||||
|
* upstream Besu.
|
||||||
|
*
|
||||||
|
* @param roundChangeIdentifier the round change identifier
|
||||||
|
* @param preparedRoundMetadata the prepared round metadata
|
||||||
|
*/
|
||||||
|
public RoundChangePayload(
|
||||||
|
final ConsensusRoundIdentifier roundChangeIdentifier,
|
||||||
|
final Optional<PreparedRoundMetadata> preparedRoundMetadata) {
|
||||||
|
this(roundChangeIdentifier, preparedRoundMetadata, Optional.empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ConsensusRoundIdentifier getRoundIdentifier() {
|
||||||
|
return roundChangeIdentifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets prepared round metadata.
|
||||||
|
*
|
||||||
|
* @return the prepared round metadata
|
||||||
|
*/
|
||||||
|
public Optional<PreparedRoundMetadata> getPreparedRoundMetadata() {
|
||||||
|
return preparedRoundMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The author's post-quantum seal, when the message carries one.
|
||||||
|
*
|
||||||
|
* @return the seal, or empty
|
||||||
|
*/
|
||||||
|
public Optional<FalconSeal> getFalconSeal() {
|
||||||
|
return falconSeal;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void writeTo(final RLPOutput rlpOutput) {
|
||||||
|
// RLP encode of the message data content (round identifier and prepared certificate)
|
||||||
|
rlpOutput.startList();
|
||||||
|
writeConsensusRound(rlpOutput);
|
||||||
|
|
||||||
|
rlpOutput.startList();
|
||||||
|
preparedRoundMetadata.ifPresent(prm -> prm.writeTo(rlpOutput));
|
||||||
|
rlpOutput.endList();
|
||||||
|
|
||||||
|
// The seal is written only when present, so a seal-less round-change is identical to upstream.
|
||||||
|
if (falconSeal.isPresent()) {
|
||||||
|
final FalconSeal fs = falconSeal.get();
|
||||||
|
rlpOutput.startList();
|
||||||
|
rlpOutput.writeIntScalar(fs.getValidatorIndex());
|
||||||
|
rlpOutput.writeBytes(fs.getSignature());
|
||||||
|
rlpOutput.endList();
|
||||||
|
}
|
||||||
|
|
||||||
|
rlpOutput.endList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read from rlp input and return round change payload.
|
||||||
|
*
|
||||||
|
* @param rlpInput the rlp input
|
||||||
|
* @return the round change payload
|
||||||
|
*/
|
||||||
|
public static RoundChangePayload readFrom(final RLPInput rlpInput) {
|
||||||
|
rlpInput.enterList();
|
||||||
|
final ConsensusRoundIdentifier roundIdentifier = readConsensusRound(rlpInput);
|
||||||
|
final Optional<PreparedRoundMetadata> preparedRoundMetadata;
|
||||||
|
|
||||||
|
rlpInput.enterList();
|
||||||
|
if (rlpInput.isEndOfCurrentList()) {
|
||||||
|
preparedRoundMetadata = Optional.empty();
|
||||||
|
} else {
|
||||||
|
preparedRoundMetadata = Optional.of(PreparedRoundMetadata.readFrom(rlpInput));
|
||||||
|
}
|
||||||
|
rlpInput.leaveList();
|
||||||
|
|
||||||
|
// AERE PQ: the OPTIONAL author seal [index, signature]. A round-change without one ends the
|
||||||
|
// list here and decodes to Optional.empty(), so the upstream shape stays untouched. When a
|
||||||
|
// fourth element exists it must be a seal and it must be last: an unknown trailing element on
|
||||||
|
// an authenticated payload is malleability, not extensibility.
|
||||||
|
Optional<FalconSeal> falconSeal = Optional.empty();
|
||||||
|
if (!rlpInput.isEndOfCurrentList()) {
|
||||||
|
rlpInput.enterList();
|
||||||
|
final int idx = rlpInput.readIntScalar();
|
||||||
|
final org.apache.tuweni.bytes.Bytes sig = rlpInput.readBytes();
|
||||||
|
rlpInput.leaveList();
|
||||||
|
falconSeal = Optional.of(new FalconSeal(idx, sig));
|
||||||
|
if (!rlpInput.isEndOfCurrentList()) {
|
||||||
|
throw new RLPException(
|
||||||
|
"RoundChange payload carries elements after the author seal; refusing an encoding the "
|
||||||
|
+ "re-encoder would silently drop");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rlpInput.leaveList();
|
||||||
|
return new RoundChangePayload(roundIdentifier, preparedRoundMetadata, falconSeal);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getMessageType() {
|
||||||
|
return TYPE;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean equals(final Object o) {
|
||||||
|
if (this == o) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (o == null || getClass() != o.getClass()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
RoundChangePayload that = (RoundChangePayload) o;
|
||||||
|
return Objects.equals(roundChangeIdentifier, that.roundChangeIdentifier)
|
||||||
|
&& Objects.equals(preparedRoundMetadata, that.preparedRoundMetadata)
|
||||||
|
&& Objects.equals(falconSeal, that.falconSeal);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
return Objects.hash(roundChangeIdentifier, preparedRoundMetadata, falconSeal);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return MoreObjects.toStringHelper(this)
|
||||||
|
.add("roundChangeIdentifier", roundChangeIdentifier)
|
||||||
|
.add("preparedRoundMetadata", preparedRoundMetadata)
|
||||||
|
.add("falconSeal", falconSeal.isPresent() ? "present" : "absent")
|
||||||
|
.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -329,10 +329,16 @@ public class QbftBlockHeightManager implements BaseQbftBlockHeightManager {
|
|||||||
}
|
}
|
||||||
QbftRound qbftRoundNew = currentRound.get();
|
QbftRound qbftRoundNew = currentRound.get();
|
||||||
|
|
||||||
|
RoundChange localRoundChange = null;
|
||||||
try {
|
try {
|
||||||
final RoundChange localRoundChange =
|
final Optional<org.hyperledger.besu.consensus.common.bft.FalconSeal> roundChangeSeal =
|
||||||
messageFactory.createRoundChange(
|
roundChangeSealFor(qbftRoundNew.getRoundIdentifier(), latestPreparedCertificate);
|
||||||
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
|
// Its possible the locally created RoundChange triggers the transmission of a NewRound
|
||||||
// message - so it must be handled accordingly.
|
// 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);
|
LOG.warn("Failed to create signed RoundChange message.", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
transmitter.multicastRoundChange(qbftRoundNew.getRoundIdentifier(), latestPreparedCertificate);
|
// THE SAME seal object goes on the wire. The transmitter re-creates the round-change, so
|
||||||
|
// without this the local copy would be sealed and the wire copy not - the exact defect F84
|
||||||
|
// measured on the PROPOSAL. And it must be the SAME object, not a second signing: Falcon
|
||||||
|
// signatures are randomised, so a re-signed wire copy would differ from the local one byte for
|
||||||
|
// byte. With the gate closed the OLD call is taken, call for call, so a node that emits
|
||||||
|
// nothing new goes through the same calls as upstream.
|
||||||
|
if (localRoundChange != null
|
||||||
|
&& localRoundChange.getSignedPayload().getPayload().getFalconSeal().isPresent()) {
|
||||||
|
transmitter.multicastRoundChange(localRoundChange);
|
||||||
|
} else {
|
||||||
|
transmitter.multicastRoundChange(
|
||||||
|
qbftRoundNew.getRoundIdentifier(), latestPreparedCertificate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This node's own post-quantum seal for a ROUND-CHANGE it is about to emit, or empty on every
|
||||||
|
* node where the emission gate ({@code aere.pq.roundChangePq.attachBlock}) is shut - which is
|
||||||
|
* every node today. The prepared metadata goes INTO the signed message, so a seal from a bare
|
||||||
|
* round-change cannot be replayed onto one that claims a prepared block.
|
||||||
|
*/
|
||||||
|
private Optional<org.hyperledger.besu.consensus.common.bft.FalconSeal> roundChangeSealFor(
|
||||||
|
final ConsensusRoundIdentifier roundIdentifier,
|
||||||
|
final Optional<PreparedCertificate> preparedCertificate) {
|
||||||
|
final long height = roundIdentifier.getSequenceNumber();
|
||||||
|
final long chainId =
|
||||||
|
org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer.config().chainId();
|
||||||
|
final org.apache.tuweni.bytes.Bytes32 message =
|
||||||
|
preparedCertificate.isPresent()
|
||||||
|
? org.hyperledger.besu.consensus.common.bft.PqAnchor.roundChangeMessage(
|
||||||
|
chainId,
|
||||||
|
height,
|
||||||
|
roundIdentifier.getRoundNumber(),
|
||||||
|
preparedCertificate.get().getRound(),
|
||||||
|
preparedCertificate.get().getBlock().getHash().getBytes())
|
||||||
|
: org.hyperledger.besu.consensus.common.bft.PqAnchor.roundChangeMessage(
|
||||||
|
chainId, height, roundIdentifier.getRoundNumber());
|
||||||
|
return org.hyperledger.besu.consensus.common.bft.FalconSealSupport.instance()
|
||||||
|
.signRoundChange(height, message);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@ -0,0 +1,209 @@
|
|||||||
|
/*
|
||||||
|
* Copyright contributors to Besu.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
|
||||||
|
* the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
|
||||||
|
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations under the License.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
package org.hyperledger.besu.consensus.qbft.core.validation;
|
||||||
|
|
||||||
|
import org.hyperledger.besu.consensus.common.bft.FalconSeal;
|
||||||
|
import org.hyperledger.besu.consensus.common.bft.PqAnchor;
|
||||||
|
import org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer;
|
||||||
|
import org.hyperledger.besu.consensus.common.bft.PqSignerRegistry;
|
||||||
|
import org.hyperledger.besu.consensus.qbft.core.payload.PreparedRoundMetadata;
|
||||||
|
import org.hyperledger.besu.datatypes.Address;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import org.apache.tuweni.bytes.Bytes32;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST-QUANTUM ENFORCEMENT ON THE ROUND-CHANGE. The last hot-path message (AERE PQ, 2026-08-31).
|
||||||
|
*
|
||||||
|
* <p>From the armed height onwards, a ROUND-CHANGE is not accepted without a valid post-quantum
|
||||||
|
* seal from its OWN author. The structure copies {@link PqProposalEnforcement} line for line on
|
||||||
|
* purpose: a second rendering of the same idea, written differently, diverges eventually.
|
||||||
|
*
|
||||||
|
* <p><b>WHAT THIS LAYER BUYS, said precisely.</b> With PREPARE armed, an adversary who breaks
|
||||||
|
* ECDSA cannot finalize; with PROPOSAL armed, it cannot open rounds. What it can still do is FORCE
|
||||||
|
* round changes: a forged quorum of round-changes moves every honest node to a round of the
|
||||||
|
* adversary's choosing, over and over - the remaining liveness lever, and, because a round-change
|
||||||
|
* may claim a prepared block, a lever over WHICH block gets re-proposed. This layer closes it: a
|
||||||
|
* round-change whose author cannot produce a Falcon seal does not count towards a round change on
|
||||||
|
* an armed node.
|
||||||
|
*
|
||||||
|
* <p><b>WHAT THE SEAL SIGNS.</b> Its own domain, {@code AERE-PQ-ROUNDCHANGE-1}, over (chainId,
|
||||||
|
* height, targetRound, prepared metadata). The metadata is in the preimage so a seal from a bare
|
||||||
|
* round-change cannot be pasted onto one that claims a prepared block, and vice versa.
|
||||||
|
*
|
||||||
|
* <p><b>DISARMED BY DEFAULT.</b> Without the property, {@link #fromSystemConfig()} returns null and
|
||||||
|
* the validator behaves exactly as upstream. A value that is PRESENT but unreadable REFUSES loudly:
|
||||||
|
* a node that boots disarmed because of a mistyped character looks exactly like a correctly
|
||||||
|
* configured one, right up to the day it matters.
|
||||||
|
*/
|
||||||
|
public final class PqRoundChangeEnforcement {
|
||||||
|
|
||||||
|
/** The height nothing ever reaches: disarmed. */
|
||||||
|
public static final long DISARMED = Long.MAX_VALUE;
|
||||||
|
|
||||||
|
/** The property that arms ROUND-CHANGE enforcement. */
|
||||||
|
public static final String PROPERTY_FORK_BLOCK = "aere.pq.roundChangePq.forkBlock";
|
||||||
|
|
||||||
|
/** The equivalent environment variable. */
|
||||||
|
public static final String ENV_FORK_BLOCK = "AERE_PQ_ROUNDCHANGEPQ_FORKBLOCK";
|
||||||
|
|
||||||
|
private final long armedFromBlock;
|
||||||
|
private final PqSignerRegistry registry;
|
||||||
|
private final long chainId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param armedFromBlock first height (inclusive) at which enforcement applies; {@link #DISARMED}
|
||||||
|
* for today's behaviour
|
||||||
|
* @param registry the signer registry, injected, never a singleton
|
||||||
|
* @param chainId the chain that goes into the signed message
|
||||||
|
*/
|
||||||
|
public PqRoundChangeEnforcement(
|
||||||
|
final long armedFromBlock, final PqSignerRegistry registry, final long chainId) {
|
||||||
|
this.armedFromBlock = armedFromBlock;
|
||||||
|
this.registry = registry;
|
||||||
|
this.chainId = chainId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same enforcement, with the chain id taken from the anchor configuration.
|
||||||
|
*
|
||||||
|
* <p>THE CHAIN ID IS AN ARGUMENT, NOT A GLOBAL - the PROPOSAL enforcement's own test caught the
|
||||||
|
* version that read it mid-decision. The factories fetch the value once, at construction, where
|
||||||
|
* it is visible.
|
||||||
|
*
|
||||||
|
* @param armedFromBlock first height at which enforcement applies
|
||||||
|
* @param registry the signer registry
|
||||||
|
*/
|
||||||
|
public PqRoundChangeEnforcement(final long armedFromBlock, final PqSignerRegistry registry) {
|
||||||
|
this(armedFromBlock, registry, PqAnchorProducer.config().chainId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The configured enforcement, read FRESH on every call.
|
||||||
|
*
|
||||||
|
* @return the armed enforcement, or null when the property is set nowhere
|
||||||
|
* @throws IllegalStateException AERE-PQC-ROUNDCHANGE-ENF-01 when the value is present but is not
|
||||||
|
* a non-negative decimal height
|
||||||
|
*/
|
||||||
|
public static PqRoundChangeEnforcement fromSystemConfig() {
|
||||||
|
String raw = System.getProperty(PROPERTY_FORK_BLOCK);
|
||||||
|
if (raw == null) {
|
||||||
|
raw = System.getenv(ENV_FORK_BLOCK);
|
||||||
|
}
|
||||||
|
if (raw == null || raw.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final long armedFrom;
|
||||||
|
try {
|
||||||
|
armedFrom = Long.parseLong(raw.trim());
|
||||||
|
if (armedFrom < 0) {
|
||||||
|
throw new NumberFormatException("negative");
|
||||||
|
}
|
||||||
|
} catch (final NumberFormatException e) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"AERE-PQC-ROUNDCHANGE-ENF-01: "
|
||||||
|
+ PROPERTY_FORK_BLOCK
|
||||||
|
+ " is set but not a non-negative block height: '"
|
||||||
|
+ raw
|
||||||
|
+ "'. A mistyped value must refuse, never silently disarm.");
|
||||||
|
}
|
||||||
|
return new PqRoundChangeEnforcement(armedFrom, PqSignerRegistry.falconSealSupport());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether enforcement is active at this height. */
|
||||||
|
public boolean armedAt(final long height) {
|
||||||
|
return height >= armedFromBlock;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decides whether a ROUND-CHANGE may be accepted.
|
||||||
|
*
|
||||||
|
* @param height the height the round-change targets (the round's sequence number)
|
||||||
|
* @param targetRound the round the author wants to move to; it is part of the signed message
|
||||||
|
* @param author the RECOVERED author of the signed message (from its ECDSA signature)
|
||||||
|
* @param preparedRoundMetadata the prepared-round claim carried by the payload, if any; it is
|
||||||
|
* part of the signed message
|
||||||
|
* @param seal the post-quantum seal carried by the payload, if any
|
||||||
|
* @return empty when the round-change is acceptable; otherwise the refusal, with names and
|
||||||
|
* numbers
|
||||||
|
*/
|
||||||
|
public Optional<String> refusal(
|
||||||
|
final long height,
|
||||||
|
final int targetRound,
|
||||||
|
final Address author,
|
||||||
|
final Optional<PreparedRoundMetadata> preparedRoundMetadata,
|
||||||
|
final Optional<FalconSeal> seal) {
|
||||||
|
if (!armedAt(height)) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
if (seal == null || seal.isEmpty()) {
|
||||||
|
return Optional.of(
|
||||||
|
"AERE FULL-PQ: round-change at height " + height + " towards round " + targetRound
|
||||||
|
+ " carries NO post-quantum seal and enforcement is armed from " + armedFromBlock
|
||||||
|
+ " - the round-change is refused");
|
||||||
|
}
|
||||||
|
final FalconSeal fs = seal.get();
|
||||||
|
final Address bound;
|
||||||
|
try {
|
||||||
|
bound = registry.addressForIndexAtOwnHead(height, fs.getValidatorIndex());
|
||||||
|
} catch (final RuntimeException e) {
|
||||||
|
return Optional.of(
|
||||||
|
"AERE FULL-PQ: registry refused index " + fs.getValidatorIndex() + " at height "
|
||||||
|
+ height + ": " + e.getMessage());
|
||||||
|
}
|
||||||
|
if (bound == null || !bound.equals(author)) {
|
||||||
|
return Optional.of(
|
||||||
|
"AERE FULL-PQ: round-change seal index " + fs.getValidatorIndex() + " is bound to "
|
||||||
|
+ bound + " but the round-change was authored by " + author
|
||||||
|
+ " - a seal cannot vouch for someone else's round-change");
|
||||||
|
}
|
||||||
|
|
||||||
|
final Bytes32 message;
|
||||||
|
try {
|
||||||
|
message =
|
||||||
|
preparedRoundMetadata.isPresent()
|
||||||
|
? PqAnchor.roundChangeMessage(
|
||||||
|
chainId,
|
||||||
|
height,
|
||||||
|
targetRound,
|
||||||
|
preparedRoundMetadata.get().getPreparedRound(),
|
||||||
|
preparedRoundMetadata.get().getPreparedBlockHash().getBytes())
|
||||||
|
: PqAnchor.roundChangeMessage(chainId, height, targetRound);
|
||||||
|
} catch (final RuntimeException e) {
|
||||||
|
// A message we cannot build means we cannot judge, and "cannot judge" must never be a pass:
|
||||||
|
// that would be exactly the silent disarming this file exists to refuse.
|
||||||
|
return Optional.of(
|
||||||
|
"AERE FULL-PQ: could not build the round-change message at height " + height
|
||||||
|
+ " towards round " + targetRound + ": " + e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
final boolean valid;
|
||||||
|
try {
|
||||||
|
valid = registry.verifyAtOwnHead(height, fs.getValidatorIndex(), message, fs.getSignature());
|
||||||
|
} catch (final RuntimeException e) {
|
||||||
|
return Optional.of(
|
||||||
|
"AERE FULL-PQ: verification threw for index " + fs.getValidatorIndex() + " at height "
|
||||||
|
+ height + ": " + e.getMessage());
|
||||||
|
}
|
||||||
|
if (!valid) {
|
||||||
|
return Optional.of(
|
||||||
|
"AERE FULL-PQ: post-quantum seal of index " + fs.getValidatorIndex()
|
||||||
|
+ " does NOT verify over the round-change message at height " + height
|
||||||
|
+ " towards round " + targetRound + " - the round-change is refused");
|
||||||
|
}
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,143 @@
|
|||||||
|
/*
|
||||||
|
* Copyright ConsenSys AG.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
|
||||||
|
* the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
|
||||||
|
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations under the License.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
package org.hyperledger.besu.consensus.qbft.core.validation;
|
||||||
|
|
||||||
|
import org.hyperledger.besu.consensus.common.bft.payload.SignedData;
|
||||||
|
import org.hyperledger.besu.consensus.qbft.core.payload.PreparedRoundMetadata;
|
||||||
|
import org.hyperledger.besu.consensus.qbft.core.payload.RoundChangePayload;
|
||||||
|
import org.hyperledger.besu.datatypes.Address;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import com.google.common.annotations.VisibleForTesting;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Note: This does not validate that the received payload is for a future round, only that it was
|
||||||
|
* signed by a known validator, and is for the current chain height. Future-round check must be
|
||||||
|
* performed elsewhere (eg. the BlockHeightManager)
|
||||||
|
*
|
||||||
|
* <p>AERE (2026-08-31): an OPTIONAL post-quantum enforcement hook, gated on height (see {@link
|
||||||
|
* PqRoundChangeEnforcement}). When no enforcement is supplied, behaviour is byte for byte the
|
||||||
|
* upstream one - and that is the configuration of every node today. Same pattern as the PREPARE,
|
||||||
|
* commit and PROPOSAL validators, deliberately: a second rendering of the same idea, written
|
||||||
|
* differently, diverges eventually.
|
||||||
|
*
|
||||||
|
* <p>The 2-argument constructor - the one production code calls, from both {@code
|
||||||
|
* MessageValidatorFactory} and {@code ProposalValidator} (the justification path) - wires the
|
||||||
|
* enforcement itself from the system configuration. That self-wiring is the security property: an
|
||||||
|
* enforcement that had to be passed in explicitly could be dropped by any refactor without a
|
||||||
|
* single test failing. It also means round-changes are judged in BOTH places they arrive:
|
||||||
|
* standalone, and inside a proposal's round-change certificate.
|
||||||
|
*/
|
||||||
|
public class RoundChangePayloadValidator {
|
||||||
|
|
||||||
|
private static final String ERROR_PREFIX = "Invalid RoundChange Payload";
|
||||||
|
private static final Logger LOG = LoggerFactory.getLogger(RoundChangePayloadValidator.class);
|
||||||
|
|
||||||
|
private final Collection<Address> validators;
|
||||||
|
private final long chainHeight;
|
||||||
|
private final PqRoundChangeEnforcement pqEnforcement;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instantiates a new Round change payload validator, with the post-quantum enforcement wired
|
||||||
|
* from the system configuration. This is the constructor production code calls.
|
||||||
|
*
|
||||||
|
* @param validators the validators
|
||||||
|
* @param chainHeight the chain height
|
||||||
|
*/
|
||||||
|
public RoundChangePayloadValidator(final Collection<Address> validators, final long chainHeight) {
|
||||||
|
this(validators, chainHeight, PqRoundChangeEnforcement.fromSystemConfig());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instantiates a new Round change payload validator with an explicit post-quantum enforcement.
|
||||||
|
* Test seam; production goes through the 2-argument constructor above.
|
||||||
|
*
|
||||||
|
* @param validators the validators
|
||||||
|
* @param chainHeight the chain height
|
||||||
|
* @param pqEnforcement the post-quantum enforcement, or null for upstream behaviour
|
||||||
|
*/
|
||||||
|
@VisibleForTesting
|
||||||
|
public RoundChangePayloadValidator(
|
||||||
|
final Collection<Address> validators,
|
||||||
|
final long chainHeight,
|
||||||
|
final PqRoundChangeEnforcement pqEnforcement) {
|
||||||
|
this.validators = validators;
|
||||||
|
this.chainHeight = chainHeight;
|
||||||
|
this.pqEnforcement = pqEnforcement;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate.
|
||||||
|
*
|
||||||
|
* @param signedPayload the signed payload
|
||||||
|
* @return the boolean
|
||||||
|
*/
|
||||||
|
public boolean validate(final SignedData<RoundChangePayload> signedPayload) {
|
||||||
|
|
||||||
|
if (!validators.contains(signedPayload.getAuthor())) {
|
||||||
|
LOG.info("{}: did not originate from a recognized validator.", ERROR_PREFIX);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final RoundChangePayload payload = signedPayload.getPayload();
|
||||||
|
|
||||||
|
if (payload.getRoundIdentifier().getSequenceNumber() != chainHeight) {
|
||||||
|
LOG.info("{}: did not target expected height", ERROR_PREFIX);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final int targetRound = payload.getRoundIdentifier().getRoundNumber();
|
||||||
|
if (targetRound <= 0) {
|
||||||
|
LOG.info("{}: must contain a positive target round number", ERROR_PREFIX);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.getPreparedRoundMetadata().isPresent()) {
|
||||||
|
final PreparedRoundMetadata metadata = payload.getPreparedRoundMetadata().get();
|
||||||
|
if (metadata.getPreparedRound() >= targetRound) {
|
||||||
|
LOG.info("{}: prepared metadata is from a round ahead of target round", ERROR_PREFIX);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metadata.getPreparedRound() < 0) {
|
||||||
|
LOG.info("{}: prepared metadata is from a negative round number", ERROR_PREFIX);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AERE full-PQ: from the armed height, a round-change is not accepted without a valid
|
||||||
|
// post-quantum seal from its OWN author. Below the height, and on every node without the
|
||||||
|
// property, this is a null check and nothing more - the upstream path, call for call.
|
||||||
|
if (pqEnforcement != null) {
|
||||||
|
final Optional<String> refusal =
|
||||||
|
pqEnforcement.refusal(
|
||||||
|
payload.getRoundIdentifier().getSequenceNumber(),
|
||||||
|
targetRound,
|
||||||
|
signedPayload.getAuthor(),
|
||||||
|
payload.getPreparedRoundMetadata(),
|
||||||
|
payload.getFalconSeal());
|
||||||
|
if (refusal.isPresent()) {
|
||||||
|
LOG.info("{}: {}", ERROR_PREFIX, refusal.get());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,88 @@
|
|||||||
|
/*
|
||||||
|
* Copyright contributors to Besu.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
|
||||||
|
* the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
|
||||||
|
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations under the License.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
package org.hyperledger.besu.consensus.qbft.core.network;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
|
||||||
|
import org.hyperledger.besu.consensus.common.bft.ConsensusRoundIdentifier;
|
||||||
|
import org.hyperledger.besu.consensus.common.bft.FalconSeal;
|
||||||
|
import org.hyperledger.besu.consensus.common.bft.network.ValidatorMulticaster;
|
||||||
|
import org.hyperledger.besu.consensus.qbft.core.messagedata.RoundChangeMessageData;
|
||||||
|
import org.hyperledger.besu.consensus.qbft.core.messagewrappers.RoundChange;
|
||||||
|
import org.hyperledger.besu.consensus.qbft.core.payload.MessageFactory;
|
||||||
|
import org.hyperledger.besu.consensus.qbft.core.types.QbftBlockCodec;
|
||||||
|
import org.hyperledger.besu.cryptoservices.NodeKeyUtils;
|
||||||
|
import org.hyperledger.besu.ethereum.p2p.rlpx.wire.MessageData;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import org.apache.tuweni.bytes.Bytes;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE PLUMBING between the local round-change and the one on the wire.
|
||||||
|
*
|
||||||
|
* <p>This test exists because of what F84 measured on the PROPOSAL (2026-08-30): the transmitter
|
||||||
|
* re-created the message from scratch, so the local copy carried a seal and the wire copy did not,
|
||||||
|
* and every unit test passed because none looked at what actually leaves the node. The round-change
|
||||||
|
* path has the same shape - the height manager builds a local round-change AND the transmitter used
|
||||||
|
* to build its own - so it gets the same test: the sealed overload must put the EXACT object it was
|
||||||
|
* handed on the wire.
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
public class RoundChangeSealPlumbingTest {
|
||||||
|
|
||||||
|
// A round-change targets a positive round.
|
||||||
|
private static final ConsensusRoundIdentifier ROUND_ID = new ConsensusRoundIdentifier(1, 1);
|
||||||
|
|
||||||
|
@Mock private QbftBlockCodec blockEncoder;
|
||||||
|
@Mock private ValidatorMulticaster multicaster;
|
||||||
|
|
||||||
|
private RoundChange sentRoundChange(final Optional<FalconSeal> seal) {
|
||||||
|
final MessageFactory factory = new MessageFactory(NodeKeyUtils.generate(), blockEncoder);
|
||||||
|
final QbftMessageTransmitter transmitter = new QbftMessageTransmitter(factory, multicaster);
|
||||||
|
|
||||||
|
if (seal.isPresent()) {
|
||||||
|
// The sealed path: the caller builds ONCE and the transmitter must not rebuild - Falcon
|
||||||
|
// signatures are randomised, so a rebuild would put a different object on the wire.
|
||||||
|
final RoundChange built = factory.createRoundChange(ROUND_ID, Optional.empty(), seal);
|
||||||
|
transmitter.multicastRoundChange(built);
|
||||||
|
} else {
|
||||||
|
transmitter.multicastRoundChange(ROUND_ID, Optional.empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
final ArgumentCaptor<MessageData> captor = ArgumentCaptor.forClass(MessageData.class);
|
||||||
|
verify(multicaster).send(captor.capture());
|
||||||
|
return RoundChangeMessageData.fromMessageData(captor.getValue()).decode(blockEncoder);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void theSealHandedToTheTransmitterIsTheSealOnTheWire() {
|
||||||
|
final FalconSeal seal = new FalconSeal(4, Bytes.fromHexString("0xdeadbeef"));
|
||||||
|
final RoundChange wire = sentRoundChange(Optional.of(seal));
|
||||||
|
assertThat(wire.getSignedPayload().getPayload().getFalconSeal()).contains(seal);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void withoutASealTheWireRoundChangeCarriesNone() {
|
||||||
|
final RoundChange wire = sentRoundChange(Optional.empty());
|
||||||
|
assertThat(wire.getSignedPayload().getPayload().getFalconSeal()).isEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,236 @@
|
|||||||
|
/*
|
||||||
|
* Copyright contributors to Besu.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
|
||||||
|
* the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
|
||||||
|
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations under the License.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
package org.hyperledger.besu.consensus.qbft.core.payload;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
import org.hyperledger.besu.consensus.common.bft.ConsensusRoundIdentifier;
|
||||||
|
import org.hyperledger.besu.consensus.common.bft.FalconSeal;
|
||||||
|
import org.hyperledger.besu.consensus.common.bft.PqAnchor;
|
||||||
|
import org.hyperledger.besu.datatypes.Hash;
|
||||||
|
import org.hyperledger.besu.ethereum.rlp.BytesValueRLPInput;
|
||||||
|
import org.hyperledger.besu.ethereum.rlp.BytesValueRLPOutput;
|
||||||
|
import org.hyperledger.besu.ethereum.rlp.RLPException;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import org.apache.tuweni.bytes.Bytes;
|
||||||
|
import org.apache.tuweni.bytes.Bytes32;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The wire can carry a post-quantum seal on a ROUND-CHANGE, and NOTHING emits one yet (AERE PQ,
|
||||||
|
* 2026-08-31, the last hot-path message).
|
||||||
|
*
|
||||||
|
* <p>The test that matters most is the first one: a round-change without a seal encodes EXACTLY as
|
||||||
|
* upstream does, compared against a hand-built expected form instead of against a run of the code
|
||||||
|
* under test.
|
||||||
|
*
|
||||||
|
* <p>The strictness tests close the malleability door the payload's javadoc names: an
|
||||||
|
* authenticated payload whose decoder silently ignored trailing elements would let two different
|
||||||
|
* byte strings authenticate to the same author.
|
||||||
|
*/
|
||||||
|
public class RoundChangePayloadPqTest {
|
||||||
|
|
||||||
|
private static final ConsensusRoundIdentifier ROUND_ID = new ConsensusRoundIdentifier(7, 3);
|
||||||
|
private static final Hash PREPARED_HASH = Hash.hash(Bytes.of(1, 2, 3));
|
||||||
|
|
||||||
|
/** The old bare form: RLP[ sequence, round, [] ]. Built by hand. */
|
||||||
|
private static Bytes oldBareForm() {
|
||||||
|
final BytesValueRLPOutput out = new BytesValueRLPOutput();
|
||||||
|
out.startList();
|
||||||
|
out.writeLongScalar(ROUND_ID.getSequenceNumber());
|
||||||
|
out.writeIntScalar(ROUND_ID.getRoundNumber());
|
||||||
|
out.startList();
|
||||||
|
out.endList();
|
||||||
|
out.endList();
|
||||||
|
return out.encoded();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void aRoundChangeWithoutASealEncodesEXACTLYAsBefore() {
|
||||||
|
final RoundChangePayload p = new RoundChangePayload(ROUND_ID, Optional.empty());
|
||||||
|
assertThat(p.encoded()).isEqualTo(oldBareForm());
|
||||||
|
// and the signature hash, which is precisely what binds the author to the message
|
||||||
|
assertThat(p.hashForSignature())
|
||||||
|
.isEqualTo(
|
||||||
|
new RoundChangePayload(ROUND_ID, Optional.empty(), Optional.empty()).hashForSignature());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void aRoundChangeWithPreparedMetadataAndNoSealReadsBackIdentical() {
|
||||||
|
final RoundChangePayload p =
|
||||||
|
new RoundChangePayload(
|
||||||
|
ROUND_ID, Optional.of(new PreparedRoundMetadata(PREPARED_HASH, 1)));
|
||||||
|
final Bytes encoded = p.encoded();
|
||||||
|
final RoundChangePayload read =
|
||||||
|
RoundChangePayload.readFrom(new BytesValueRLPInput(encoded, false));
|
||||||
|
assertThat(read.getFalconSeal()).isEmpty();
|
||||||
|
assertThat(read.getPreparedRoundMetadata()).isPresent();
|
||||||
|
assertThat(read.getPreparedRoundMetadata().get().getPreparedRound()).isEqualTo(1);
|
||||||
|
assertThat(read.getPreparedRoundMetadata().get().getPreparedBlockHash())
|
||||||
|
.isEqualTo(PREPARED_HASH);
|
||||||
|
assertThat(read.encoded()).isEqualTo(encoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void aRoundChangeWithASealReadsBackIdentical() {
|
||||||
|
final FalconSeal seal = new FalconSeal(4, Bytes.fromHexString("0xdeadbeef"));
|
||||||
|
final RoundChangePayload p =
|
||||||
|
new RoundChangePayload(ROUND_ID, Optional.empty(), Optional.of(seal));
|
||||||
|
final Bytes encoded = p.encoded();
|
||||||
|
|
||||||
|
// it is longer than the old form, and CONTAINS it as a prefix of the content
|
||||||
|
assertThat(encoded.size()).isGreaterThan(oldBareForm().size());
|
||||||
|
|
||||||
|
final RoundChangePayload read =
|
||||||
|
RoundChangePayload.readFrom(new BytesValueRLPInput(encoded, false));
|
||||||
|
assertThat(read.getFalconSeal()).isPresent();
|
||||||
|
assertThat(read.getFalconSeal().get().getValidatorIndex()).isEqualTo(4);
|
||||||
|
assertThat(read.getFalconSeal().get().getSignature())
|
||||||
|
.isEqualTo(Bytes.fromHexString("0xdeadbeef"));
|
||||||
|
assertThat(read.encoded()).isEqualTo(encoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void aSealedRoundChangeWithPreparedMetadataReadsBackIdentical() {
|
||||||
|
final FalconSeal seal = new FalconSeal(2, Bytes.fromHexString("0xfeedface"));
|
||||||
|
final RoundChangePayload p =
|
||||||
|
new RoundChangePayload(
|
||||||
|
ROUND_ID, Optional.of(new PreparedRoundMetadata(PREPARED_HASH, 2)), Optional.of(seal));
|
||||||
|
final Bytes encoded = p.encoded();
|
||||||
|
final RoundChangePayload read =
|
||||||
|
RoundChangePayload.readFrom(new BytesValueRLPInput(encoded, false));
|
||||||
|
assertThat(read.getFalconSeal()).isPresent();
|
||||||
|
assertThat(read.getPreparedRoundMetadata()).isPresent();
|
||||||
|
assertThat(read.encoded()).isEqualTo(encoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void theGoldenBytesMatchTheSecondClient() {
|
||||||
|
// The SAME two hex strings are asserted by the second client (AereQbftPrepareSealWireProofTests,
|
||||||
|
// AurRcFaraSigiliu / AurRcCuSigiliu) for the same sequence/round/seal. If either implementation
|
||||||
|
// moves, one of the two tests goes red - the cross-implementation link. A round trip through our
|
||||||
|
// own encoder would only prove that we agree with ourselves.
|
||||||
|
assertThat(new RoundChangePayload(ROUND_ID, Optional.empty()).encoded().toUnprefixedHexString())
|
||||||
|
.isEqualTo("c30703c0");
|
||||||
|
assertThat(
|
||||||
|
new RoundChangePayload(
|
||||||
|
ROUND_ID,
|
||||||
|
Optional.empty(),
|
||||||
|
Optional.of(new FalconSeal(4, Bytes.fromHexString("0xdeadbeef"))))
|
||||||
|
.encoded()
|
||||||
|
.toUnprefixedHexString())
|
||||||
|
.isEqualTo("ca0703c0c60484deadbeef");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void aSealChangesTheSignatureHash() {
|
||||||
|
// If it did not change it, the author's ECDSA signature would not cover the seal, and anyone
|
||||||
|
// could paste a foreign index onto an otherwise valid round-change.
|
||||||
|
final RoundChangePayload without = new RoundChangePayload(ROUND_ID, Optional.empty());
|
||||||
|
final RoundChangePayload with =
|
||||||
|
new RoundChangePayload(
|
||||||
|
ROUND_ID,
|
||||||
|
Optional.empty(),
|
||||||
|
Optional.of(new FalconSeal(4, Bytes.fromHexString("0xdeadbeef"))));
|
||||||
|
assertThat(with.hashForSignature()).isNotEqualTo(without.hashForSignature());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void anElementAfterTheSealIsRefused() {
|
||||||
|
// A decoder that ignored it would re-encode without it: two different byte strings would
|
||||||
|
// authenticate to the same author. It is refused.
|
||||||
|
final BytesValueRLPOutput out = new BytesValueRLPOutput();
|
||||||
|
out.startList();
|
||||||
|
out.writeLongScalar(ROUND_ID.getSequenceNumber());
|
||||||
|
out.writeIntScalar(ROUND_ID.getRoundNumber());
|
||||||
|
out.startList();
|
||||||
|
out.endList();
|
||||||
|
out.startList();
|
||||||
|
out.writeIntScalar(4);
|
||||||
|
out.writeBytes(Bytes.fromHexString("0xdeadbeef"));
|
||||||
|
out.endList();
|
||||||
|
out.writeIntScalar(1); // the trailing element nothing accounts for
|
||||||
|
out.endList();
|
||||||
|
|
||||||
|
assertThatThrownBy(
|
||||||
|
() -> RoundChangePayload.readFrom(new BytesValueRLPInput(out.encoded(), false)))
|
||||||
|
.isInstanceOf(RLPException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void aFourthElementThatIsNotASealIsRefused() {
|
||||||
|
final BytesValueRLPOutput out = new BytesValueRLPOutput();
|
||||||
|
out.startList();
|
||||||
|
out.writeLongScalar(ROUND_ID.getSequenceNumber());
|
||||||
|
out.writeIntScalar(ROUND_ID.getRoundNumber());
|
||||||
|
out.startList();
|
||||||
|
out.endList();
|
||||||
|
out.writeBytes(Bytes.fromHexString("0x01")); // not a [index, signature] list
|
||||||
|
out.endList();
|
||||||
|
|
||||||
|
assertThatThrownBy(
|
||||||
|
() -> RoundChangePayload.readFrom(new BytesValueRLPInput(out.encoded(), false)))
|
||||||
|
.isInstanceOf(RLPException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- domain separation: the security part of the design --------------------------------------
|
||||||
|
|
||||||
|
private static final Bytes32 DIGEST =
|
||||||
|
Bytes32.fromHexString("0x000000000000000000000000000000000000000000000000000000000000002a");
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void theRoundChangeMessageIsNoneOfItsThreeSiblings() {
|
||||||
|
// If any two were the same, a seal given HONESTLY in one role could be replayed in the other:
|
||||||
|
// "move on" counted as a vote, an offer, or the other way around.
|
||||||
|
final Bytes32 rc = PqAnchor.roundChangeMessage(2800L, 100L, 3);
|
||||||
|
assertThat(rc).isNotEqualTo(PqAnchor.prepareMessage(2800L, 100L, 3, DIGEST));
|
||||||
|
assertThat(rc).isNotEqualTo(PqAnchor.proposalMessage(2800L, 100L, 3, DIGEST));
|
||||||
|
assertThat(rc).isNotEqualTo(PqAnchor.commitMessage(2800L, 100L, DIGEST));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void theRoundChangeMessageDependsOnTargetRoundChainAndHeight() {
|
||||||
|
final Bytes32 base = PqAnchor.roundChangeMessage(2800L, 100L, 3);
|
||||||
|
assertThat(PqAnchor.roundChangeMessage(2800L, 100L, 4)).isNotEqualTo(base);
|
||||||
|
assertThat(PqAnchor.roundChangeMessage(2801L, 100L, 3)).isNotEqualTo(base);
|
||||||
|
assertThat(PqAnchor.roundChangeMessage(2800L, 101L, 3)).isNotEqualTo(base);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void aBareAndAPreparedRoundChangeMessageDiffer() {
|
||||||
|
// The metadata is in the preimage: "move on" and "move on and re-propose THIS block" are two
|
||||||
|
// different assertions, and their seals must not be interchangeable.
|
||||||
|
final Bytes32 bare = PqAnchor.roundChangeMessage(2800L, 100L, 3);
|
||||||
|
final Bytes32 prepared = PqAnchor.roundChangeMessage(2800L, 100L, 3, 1, DIGEST);
|
||||||
|
assertThat(prepared).isNotEqualTo(bare);
|
||||||
|
assertThat(PqAnchor.roundChangeMessage(2800L, 100L, 3, 2, DIGEST)).isNotEqualTo(prepared);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void theRoundChangeMessageRefusesImpossibleInputs() {
|
||||||
|
assertThatThrownBy(() -> PqAnchor.roundChangeMessage(2800L, -1L, 3))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class);
|
||||||
|
assertThatThrownBy(() -> PqAnchor.roundChangeMessage(2800L, 100L, -1))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class);
|
||||||
|
assertThatThrownBy(() -> PqAnchor.roundChangeMessage(2800L, 100L, 3, -1, DIGEST))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class);
|
||||||
|
assertThatThrownBy(
|
||||||
|
() -> PqAnchor.roundChangeMessage(2800L, 100L, 3, 1, Bytes.fromHexString("0x00")))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,422 @@
|
|||||||
|
/*
|
||||||
|
* Copyright contributors to Besu.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
|
||||||
|
* the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
|
||||||
|
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations under the License.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
package org.hyperledger.besu.consensus.qbft.core.validation;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
import org.hyperledger.besu.consensus.common.bft.FalconSeal;
|
||||||
|
import org.hyperledger.besu.consensus.common.bft.PqAnchor;
|
||||||
|
import org.hyperledger.besu.consensus.common.bft.PqSignerRegistry;
|
||||||
|
import org.hyperledger.besu.consensus.common.bft.SealScheme;
|
||||||
|
import org.hyperledger.besu.consensus.common.bft.SealSchemes;
|
||||||
|
import org.hyperledger.besu.consensus.qbft.core.payload.PreparedRoundMetadata;
|
||||||
|
import org.hyperledger.besu.crypto.SecureRandomProvider;
|
||||||
|
import org.hyperledger.besu.datatypes.Address;
|
||||||
|
import org.hyperledger.besu.datatypes.Hash;
|
||||||
|
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import org.apache.tuweni.bytes.Bytes;
|
||||||
|
import org.apache.tuweni.bytes.Bytes32;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ROUND-CHANGE ENFORCEMENT. The structure follows PqProposalEnforcementTest deliberately - the
|
||||||
|
* same ways of being wrong, each with the test that closes it, plus the one that is round-change
|
||||||
|
* specific: the PREPARED METADATA is in the preimage, so a seal from a bare round-change cannot be
|
||||||
|
* replayed onto one that claims a prepared block, in either direction.
|
||||||
|
*
|
||||||
|
* <ol>
|
||||||
|
* <li>below the arming height NOTHING changes - the condition for the binary to sit on the fleet;
|
||||||
|
* <li>above it, a ROUND-CHANGE without a seal is refused;
|
||||||
|
* <li>a seal from ANOTHER validator does not vouch for this author;
|
||||||
|
* <li>a seal over a DIFFERENT MESSAGE does not pass - tested against the PREPARE, COMMIT and
|
||||||
|
* PROPOSAL messages, because a round-change seal that passed under any of those domains
|
||||||
|
* would let an honest seal be replayed as a different kind of assertion;
|
||||||
|
* <li>a seal from a DIFFERENT TARGET ROUND does not pass;
|
||||||
|
* <li>a seal from a BARE round-change does not pass on one claiming a prepared block, and vice
|
||||||
|
* versa;
|
||||||
|
* <li>an index the registry binds to nobody does not pass;
|
||||||
|
* <li>a mistyped configuration REFUSES, it does not disarm;
|
||||||
|
* <li>the enforcement gate and the emission gate are two different switches.
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* <p>The keys are REAL Falcon keys, generated in-process, and verification goes through the real
|
||||||
|
* scheme. A test with fake signatures would prove that we can compare strings, not that the
|
||||||
|
* enforcement enforces.
|
||||||
|
*/
|
||||||
|
class PqRoundChangeEnforcementTest {
|
||||||
|
|
||||||
|
private static final long ARMED_FROM = 1_000_000L;
|
||||||
|
private static final int TARGET_ROUND = 3;
|
||||||
|
private static final Address AUTHOR_0 = Address.fromHexString("0x" + "aa".repeat(20));
|
||||||
|
private static final Address VALIDATOR_1 = Address.fromHexString("0x" + "bb".repeat(20));
|
||||||
|
private static final Hash PREPARED_DIGEST = Hash.hash(Bytes.of(8, 8, 8));
|
||||||
|
private static final long CHAIN_ID = 2800L;
|
||||||
|
|
||||||
|
private final SecureRandom random = SecureRandomProvider.createSecureRandom();
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void clearProperty() {
|
||||||
|
System.clearProperty(PqRoundChangeEnforcement.PROPERTY_FORK_BLOCK);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test registry: programmed index-to-address bindings + verification through the REAL scheme. */
|
||||||
|
private static final class TestRegistry implements PqSignerRegistry {
|
||||||
|
final Map<Integer, Address> bindings;
|
||||||
|
final Map<Integer, byte[]> keys;
|
||||||
|
|
||||||
|
TestRegistry(final Map<Integer, Address> bindings, final Map<Integer, byte[]> keys) {
|
||||||
|
this.bindings = bindings;
|
||||||
|
this.keys = keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Address addressForIndexAtHistoric(final long blockNumber, final int validatorIndex) {
|
||||||
|
return bindings.get(validatorIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Address addressForIndexAtOwnHead(final long blockNumber, final int validatorIndex) {
|
||||||
|
return bindings.get(validatorIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean verifyAtHistoric(
|
||||||
|
final long blockNumber, final int validatorIndex, final Bytes message, final Bytes signature) {
|
||||||
|
return verifyAtOwnHead(blockNumber, validatorIndex, message, signature);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean verifyAtOwnHead(
|
||||||
|
final long blockNumber, final int validatorIndex, final Bytes message, final Bytes signature) {
|
||||||
|
final byte[] pk = keys.get(validatorIndex);
|
||||||
|
if (pk == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return SealSchemes.FALCON_512.verifyRaw(pk, message.toArray(), signature.toArray());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private record World(
|
||||||
|
PqRoundChangeEnforcement enforcement,
|
||||||
|
FalconSeal validBare0,
|
||||||
|
SealScheme.GeneratedPair k0,
|
||||||
|
SealScheme.GeneratedPair k1) {}
|
||||||
|
|
||||||
|
private Bytes32 bareMessage(final long h, final int targetRound) {
|
||||||
|
return PqAnchor.roundChangeMessage(CHAIN_ID, h, targetRound);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Bytes32 preparedMessage(final long h, final int targetRound, final int preparedRound) {
|
||||||
|
return PqAnchor.roundChangeMessage(
|
||||||
|
CHAIN_ID, h, targetRound, preparedRound, PREPARED_DIGEST.getBytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Optional<PreparedRoundMetadata> preparedMetadata(final int preparedRound) {
|
||||||
|
return Optional.of(new PreparedRoundMetadata(PREPARED_DIGEST, preparedRound));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Two validators with real Falcon keys; validator 0's seal over the BARE round-change message. */
|
||||||
|
private World world() {
|
||||||
|
final SealScheme.GeneratedPair k0 = SealSchemes.FALCON_512.generate(random);
|
||||||
|
final SealScheme.GeneratedPair k1 = SealSchemes.FALCON_512.generate(random);
|
||||||
|
final byte[] sig0 =
|
||||||
|
SealSchemes.FALCON_512
|
||||||
|
.sign(k0.privateKey(), bareMessage(ARMED_FROM, TARGET_ROUND).toArray())
|
||||||
|
.orElseThrow();
|
||||||
|
final TestRegistry reg =
|
||||||
|
new TestRegistry(
|
||||||
|
Map.of(0, AUTHOR_0, 1, VALIDATOR_1),
|
||||||
|
Map.of(0, k0.publicRegistryForm(), 1, k1.publicRegistryForm()));
|
||||||
|
return new World(
|
||||||
|
new PqRoundChangeEnforcement(ARMED_FROM, reg, CHAIN_ID),
|
||||||
|
new FalconSeal(0, Bytes.wrap(sig0)),
|
||||||
|
k0,
|
||||||
|
k1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
// 1. BELOW the arming height NOTHING changes. The condition for the binary to sit on the fleet.
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
@Test
|
||||||
|
void belowTheArmingHeightAnUnsealedRoundChangeIsAccepted() {
|
||||||
|
final World w = world();
|
||||||
|
assertThat(w.enforcement().armedAt(ARMED_FROM - 1)).isFalse();
|
||||||
|
assertThat(
|
||||||
|
w.enforcement()
|
||||||
|
.refusal(ARMED_FROM - 1, TARGET_ROUND, AUTHOR_0, Optional.empty(), Optional.empty()))
|
||||||
|
.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
// 2. ABOVE it, a ROUND-CHANGE without a seal is refused.
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
@Test
|
||||||
|
void aboveTheHeightAnUnsealedRoundChangeIsRefused() {
|
||||||
|
final World w = world();
|
||||||
|
final Optional<String> refusal =
|
||||||
|
w.enforcement()
|
||||||
|
.refusal(ARMED_FROM, TARGET_ROUND, AUTHOR_0, Optional.empty(), Optional.empty());
|
||||||
|
assertThat(refusal).isPresent();
|
||||||
|
assertThat(refusal.get()).contains("carries NO post-quantum seal");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
// 3. A GOOD seal from the author passes - bare, and with prepared metadata.
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
@Test
|
||||||
|
void aGoodSealFromTheAuthorPasses() {
|
||||||
|
final World w = world();
|
||||||
|
assertThat(
|
||||||
|
w.enforcement()
|
||||||
|
.refusal(
|
||||||
|
ARMED_FROM,
|
||||||
|
TARGET_ROUND,
|
||||||
|
AUTHOR_0,
|
||||||
|
Optional.empty(),
|
||||||
|
Optional.of(w.validBare0())))
|
||||||
|
.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aGoodSealOverPreparedMetadataPasses() {
|
||||||
|
final World w = world();
|
||||||
|
final byte[] sig =
|
||||||
|
SealSchemes.FALCON_512
|
||||||
|
.sign(w.k0().privateKey(), preparedMessage(ARMED_FROM, TARGET_ROUND, 1).toArray())
|
||||||
|
.orElseThrow();
|
||||||
|
assertThat(
|
||||||
|
w.enforcement()
|
||||||
|
.refusal(
|
||||||
|
ARMED_FROM,
|
||||||
|
TARGET_ROUND,
|
||||||
|
AUTHOR_0,
|
||||||
|
preparedMetadata(1),
|
||||||
|
Optional.of(new FalconSeal(0, Bytes.wrap(sig)))))
|
||||||
|
.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
// 4. The same seal, a different author: it does not vouch for somebody else.
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
@Test
|
||||||
|
void aSealDoesNotVouchForAnotherAuthor() {
|
||||||
|
final World w = world();
|
||||||
|
final Optional<String> refusal =
|
||||||
|
w.enforcement()
|
||||||
|
.refusal(
|
||||||
|
ARMED_FROM, TARGET_ROUND, VALIDATOR_1, Optional.empty(), Optional.of(w.validBare0()));
|
||||||
|
assertThat(refusal).isPresent();
|
||||||
|
assertThat(refusal.get()).contains("cannot vouch for someone else's round-change");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
// 5. DOMAIN SEPARATION against all three sibling domains: a seal given HONESTLY over the
|
||||||
|
// PREPARE, COMMIT or PROPOSAL message must not pass as a ROUND-CHANGE seal.
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
@Test
|
||||||
|
void aSealOverThePREPAREMessageDoesNotPassAsROUNDCHANGE() {
|
||||||
|
final World w = world();
|
||||||
|
final Bytes32 prepareMsg =
|
||||||
|
PqAnchor.prepareMessage(CHAIN_ID, ARMED_FROM, TARGET_ROUND, PREPARED_DIGEST.getBytes());
|
||||||
|
final byte[] sig =
|
||||||
|
SealSchemes.FALCON_512.sign(w.k0().privateKey(), prepareMsg.toArray()).orElseThrow();
|
||||||
|
|
||||||
|
final Optional<String> refusal =
|
||||||
|
w.enforcement()
|
||||||
|
.refusal(
|
||||||
|
ARMED_FROM,
|
||||||
|
TARGET_ROUND,
|
||||||
|
AUTHOR_0,
|
||||||
|
Optional.empty(),
|
||||||
|
Optional.of(new FalconSeal(0, Bytes.wrap(sig))));
|
||||||
|
assertThat(refusal).isPresent();
|
||||||
|
assertThat(refusal.get()).contains("does NOT verify over the round-change message");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aSealOverTheCOMMITMessageDoesNotPassAsROUNDCHANGE() {
|
||||||
|
final World w = world();
|
||||||
|
final Bytes32 commitMsg = PqAnchor.commitMessage(CHAIN_ID, ARMED_FROM, PREPARED_DIGEST.getBytes());
|
||||||
|
final byte[] sig =
|
||||||
|
SealSchemes.FALCON_512.sign(w.k0().privateKey(), commitMsg.toArray()).orElseThrow();
|
||||||
|
|
||||||
|
final Optional<String> refusal =
|
||||||
|
w.enforcement()
|
||||||
|
.refusal(
|
||||||
|
ARMED_FROM,
|
||||||
|
TARGET_ROUND,
|
||||||
|
AUTHOR_0,
|
||||||
|
Optional.empty(),
|
||||||
|
Optional.of(new FalconSeal(0, Bytes.wrap(sig))));
|
||||||
|
assertThat(refusal).isPresent();
|
||||||
|
assertThat(refusal.get()).contains("does NOT verify over the round-change message");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aSealOverThePROPOSALMessageDoesNotPassAsROUNDCHANGE() {
|
||||||
|
final World w = world();
|
||||||
|
final Bytes32 proposalMsg =
|
||||||
|
PqAnchor.proposalMessage(CHAIN_ID, ARMED_FROM, TARGET_ROUND, PREPARED_DIGEST.getBytes());
|
||||||
|
final byte[] sig =
|
||||||
|
SealSchemes.FALCON_512.sign(w.k0().privateKey(), proposalMsg.toArray()).orElseThrow();
|
||||||
|
|
||||||
|
final Optional<String> refusal =
|
||||||
|
w.enforcement()
|
||||||
|
.refusal(
|
||||||
|
ARMED_FROM,
|
||||||
|
TARGET_ROUND,
|
||||||
|
AUTHOR_0,
|
||||||
|
Optional.empty(),
|
||||||
|
Optional.of(new FalconSeal(0, Bytes.wrap(sig))));
|
||||||
|
assertThat(refusal).isPresent();
|
||||||
|
assertThat(refusal.get()).contains("does NOT verify over the round-change message");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
// 6. The TARGET ROUND is in the preimage: a seal towards one round does not open another.
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
@Test
|
||||||
|
void aSealTowardsAnotherTARGETROUNDDoesNotPass() {
|
||||||
|
final World w = world();
|
||||||
|
final byte[] sigOtherRound =
|
||||||
|
SealSchemes.FALCON_512
|
||||||
|
.sign(w.k0().privateKey(), bareMessage(ARMED_FROM, TARGET_ROUND + 1).toArray())
|
||||||
|
.orElseThrow();
|
||||||
|
final Optional<String> refusal =
|
||||||
|
w.enforcement()
|
||||||
|
.refusal(
|
||||||
|
ARMED_FROM,
|
||||||
|
TARGET_ROUND,
|
||||||
|
AUTHOR_0,
|
||||||
|
Optional.empty(),
|
||||||
|
Optional.of(new FalconSeal(0, Bytes.wrap(sigOtherRound))));
|
||||||
|
assertThat(refusal).isPresent();
|
||||||
|
assertThat(refusal.get()).contains("does NOT verify over the round-change message");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
// 7. THE ROUND-CHANGE-SPECIFIC CASE: the prepared metadata is in the preimage, in BOTH
|
||||||
|
// directions. A bare seal on a message claiming a prepared block would let an adversary take
|
||||||
|
// an honest "just move on" and turn it into "move on AND re-propose THIS block".
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
@Test
|
||||||
|
void aBareSealDoesNotPassOnARoundChangeClaimingAPreparedBlock() {
|
||||||
|
final World w = world();
|
||||||
|
final Optional<String> refusal =
|
||||||
|
w.enforcement()
|
||||||
|
.refusal(
|
||||||
|
ARMED_FROM, TARGET_ROUND, AUTHOR_0, preparedMetadata(1), Optional.of(w.validBare0()));
|
||||||
|
assertThat(refusal).isPresent();
|
||||||
|
assertThat(refusal.get()).contains("does NOT verify over the round-change message");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aPreparedSealDoesNotPassOnABareRoundChange() {
|
||||||
|
final World w = world();
|
||||||
|
final byte[] sigPrepared =
|
||||||
|
SealSchemes.FALCON_512
|
||||||
|
.sign(w.k0().privateKey(), preparedMessage(ARMED_FROM, TARGET_ROUND, 1).toArray())
|
||||||
|
.orElseThrow();
|
||||||
|
final Optional<String> refusal =
|
||||||
|
w.enforcement()
|
||||||
|
.refusal(
|
||||||
|
ARMED_FROM,
|
||||||
|
TARGET_ROUND,
|
||||||
|
AUTHOR_0,
|
||||||
|
Optional.empty(),
|
||||||
|
Optional.of(new FalconSeal(0, Bytes.wrap(sigPrepared))));
|
||||||
|
assertThat(refusal).isPresent();
|
||||||
|
assertThat(refusal.get()).contains("does NOT verify over the round-change message");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aSealOverDifferentPreparedMetadataDoesNotPass() {
|
||||||
|
final World w = world();
|
||||||
|
final byte[] sigRound1 =
|
||||||
|
SealSchemes.FALCON_512
|
||||||
|
.sign(w.k0().privateKey(), preparedMessage(ARMED_FROM, TARGET_ROUND, 1).toArray())
|
||||||
|
.orElseThrow();
|
||||||
|
final Optional<String> refusal =
|
||||||
|
w.enforcement()
|
||||||
|
.refusal(
|
||||||
|
ARMED_FROM,
|
||||||
|
TARGET_ROUND,
|
||||||
|
AUTHOR_0,
|
||||||
|
preparedMetadata(2),
|
||||||
|
Optional.of(new FalconSeal(0, Bytes.wrap(sigRound1))));
|
||||||
|
assertThat(refusal).isPresent();
|
||||||
|
assertThat(refusal.get()).contains("does NOT verify over the round-change message");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
// 8. An index the registry binds to nobody.
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
@Test
|
||||||
|
void anUNBOUNDIndexDoesNotPass() {
|
||||||
|
final World w = world();
|
||||||
|
final Optional<String> refusal =
|
||||||
|
w.enforcement()
|
||||||
|
.refusal(
|
||||||
|
ARMED_FROM,
|
||||||
|
TARGET_ROUND,
|
||||||
|
AUTHOR_0,
|
||||||
|
Optional.empty(),
|
||||||
|
Optional.of(new FalconSeal(99, w.validBare0().getSignature())));
|
||||||
|
assertThat(refusal).isPresent();
|
||||||
|
assertThat(refusal.get()).contains("is bound to null");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
// 9. CONFIGURATION: absent = disarmed; a mistyped value = REFUSAL, never a silent disarming.
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
@Test
|
||||||
|
void withoutThePropertyTheEnforcementIsNULL() {
|
||||||
|
assertThat(PqRoundChangeEnforcement.fromSystemConfig()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aGoodValueArms() {
|
||||||
|
System.setProperty(PqRoundChangeEnforcement.PROPERTY_FORK_BLOCK, "1234567");
|
||||||
|
final PqRoundChangeEnforcement e = PqRoundChangeEnforcement.fromSystemConfig();
|
||||||
|
assertThat(e).isNotNull();
|
||||||
|
assertThat(e.armedAt(1_234_566L)).isFalse();
|
||||||
|
assertThat(e.armedAt(1_234_567L)).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aMISTYPEDValueRefusesLoudly() {
|
||||||
|
for (final String bad : new String[] {"not-a-number", "1_234_567", "-1", "1e6"}) {
|
||||||
|
System.setProperty(PqRoundChangeEnforcement.PROPERTY_FORK_BLOCK, bad);
|
||||||
|
assertThatThrownBy(PqRoundChangeEnforcement::fromSystemConfig)
|
||||||
|
.as("the value '%s'", bad)
|
||||||
|
.isInstanceOf(IllegalStateException.class)
|
||||||
|
.hasMessageContaining("AERE-PQC-ROUNDCHANGE-ENF-01");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void theENFORCEMENTGateIsNotTheEMISSIONGate() {
|
||||||
|
// Two distinct properties: a fleet can EMIT for months before anything ENFORCES. If they were
|
||||||
|
// one switch, the first node that started emitting would also start refusing - a flag day.
|
||||||
|
assertThat(PqRoundChangeEnforcement.PROPERTY_FORK_BLOCK)
|
||||||
|
.isNotEqualTo("aere.pq.roundChangePq.attachBlock");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,125 @@
|
|||||||
|
/*
|
||||||
|
* Copyright contributors to Besu.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
|
||||||
|
* the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
|
||||||
|
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations under the License.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
package org.hyperledger.besu.consensus.qbft.core.validation;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
import org.hyperledger.besu.consensus.common.bft.ConsensusRoundIdentifier;
|
||||||
|
import org.hyperledger.besu.consensus.common.bft.PqSignerRegistry;
|
||||||
|
import org.hyperledger.besu.consensus.qbft.core.messagewrappers.RoundChange;
|
||||||
|
import org.hyperledger.besu.consensus.qbft.core.payload.MessageFactory;
|
||||||
|
import org.hyperledger.besu.consensus.qbft.core.types.QbftBlockCodec;
|
||||||
|
import org.hyperledger.besu.cryptoservices.NodeKey;
|
||||||
|
import org.hyperledger.besu.cryptoservices.NodeKeyUtils;
|
||||||
|
import org.hyperledger.besu.datatypes.Address;
|
||||||
|
import org.hyperledger.besu.ethereum.core.Util;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import org.apache.tuweni.bytes.Bytes;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE WIRING, not the class: does {@link RoundChangePayloadValidator} actually CALL the
|
||||||
|
* enforcement?
|
||||||
|
*
|
||||||
|
* <p>Same reason the PREPARE and PROPOSAL twins exist: a class present in the binary, environment
|
||||||
|
* variables visible to the process, and code that never runs is exactly the failure paid for on
|
||||||
|
* 2026-08-28. The registry here REFUSES everything, so this does not measure cryptography (that has
|
||||||
|
* its own test), only whether the decision passes through the hook. The pair is all it takes: the
|
||||||
|
* same signed round-change, once with enforcement and once without.
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
public class RoundChangePayloadValidatorPqWiringTest {
|
||||||
|
|
||||||
|
private static final long HEIGHT = 1L;
|
||||||
|
|
||||||
|
// A round-change must target a POSITIVE round, so the identifier moves to round 1.
|
||||||
|
private final ConsensusRoundIdentifier targetRound = new ConsensusRoundIdentifier((int) HEIGHT, 1);
|
||||||
|
@Mock private QbftBlockCodec blockEncoder;
|
||||||
|
|
||||||
|
private final NodeKey nodeKey = NodeKeyUtils.generate();
|
||||||
|
private final Address author = Util.publicKeyToAddress(nodeKey.getPublicKey());
|
||||||
|
private MessageFactory messageFactory;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
public void setup() {
|
||||||
|
messageFactory = new MessageFactory(nodeKey, blockEncoder);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A registry that binds no index and verifies nothing. */
|
||||||
|
private static final class EmptyRegistry implements PqSignerRegistry {
|
||||||
|
@Override
|
||||||
|
public Address addressForIndexAtHistoric(final long blockNumber, final int validatorIndex) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Address addressForIndexAtOwnHead(final long blockNumber, final int validatorIndex) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean verifyAtHistoric(
|
||||||
|
final long blockNumber, final int validatorIndex, final Bytes message, final Bytes signature) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean verifyAtOwnHead(
|
||||||
|
final long blockNumber, final int validatorIndex, final Bytes message, final Bytes signature) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private RoundChange unsealedRoundChange() {
|
||||||
|
return messageFactory.createRoundChange(targetRound, Optional.empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void withoutEnforcementAnUnsealedRoundChangeISValid() {
|
||||||
|
final RoundChangePayloadValidator validator =
|
||||||
|
new RoundChangePayloadValidator(List.of(author), HEIGHT, null);
|
||||||
|
assertThat(validator.validate(unsealedRoundChange().getSignedPayload())).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void withTheEnforcementARMEDTheSameRoundChangeISRefused() {
|
||||||
|
// THE SAME message as above. The only difference is the hook, so a different result means it
|
||||||
|
// really is called. If this still returned true, the enforcement would be dead code.
|
||||||
|
final RoundChangePayloadValidator validator =
|
||||||
|
new RoundChangePayloadValidator(
|
||||||
|
List.of(author), HEIGHT, new PqRoundChangeEnforcement(HEIGHT, new EmptyRegistry(), 2800L));
|
||||||
|
assertThat(validator.validate(unsealedRoundChange().getSignedPayload())).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void withTheEnforcementBELOWItsHeightTheSameRoundChangeISValid() {
|
||||||
|
// The third state, closing the last way of being wrong: a hook that refused regardless of
|
||||||
|
// height would make the binary impossible to deploy. Here the enforcement exists but does not
|
||||||
|
// apply yet.
|
||||||
|
final RoundChangePayloadValidator validator =
|
||||||
|
new RoundChangePayloadValidator(
|
||||||
|
List.of(author),
|
||||||
|
HEIGHT,
|
||||||
|
new PqRoundChangeEnforcement(HEIGHT + 1, new EmptyRegistry(), 2800L));
|
||||||
|
assertThat(validator.validate(unsealedRoundChange().getSignedPayload())).isTrue();
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user