The proposal now carries an optional post-quantum seal, disarmed by default
The remaining hot-path gap, stated precisely because the imprecise version oversells: with PREPARE enforcement armed, an ECDSA-breaking adversary cannot finalize anything, since "prepared" needs a full quorum of PREPAREs. What forged proposals could still do is OPEN rounds and waste them. With proposal enforcement armed, a proposal without a valid Falcon seal from its own proposer does not open a round. Same pattern as the PREPARE layer, deliberately: an optional fifth payload element that leaves the unsealed encoding byte-identical to upstream; a third, separate emission gate; enforcement self-wired in the payload validator so no refactor can drop it silently; its own domain over (chainId, height, round, digest), tested in both directions so an honest offer seal cannot be replayed as a vote nor a vote seal as an offer. Nothing is armed anywhere: the properties are unset, and unset means never. The armed- layers table in the README says so, and says what stays classical: the ROUND-CHANGE message itself and node-level devp2p authentication. 1,042 tests, 0 failures, counted from the XML. The enforcement's negative control ran at 5 of 5: four planted defects each turned the suite red, the untouched source stayed green.
This commit is contained in:
parent
4ef6565275
commit
3afe906fb2
@ -13,11 +13,11 @@ a3b93f2602e9755d91358c9b3f473235f4d40ae23d912f797cdb03ed7e1348f9 consensus/comm
|
|||||||
e32a03de9f1452bd7444a33b084516ae3399d7a5ae7430088714f5656d3a22ea consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/BftExtraData.java
|
e32a03de9f1452bd7444a33b084516ae3399d7a5ae7430088714f5656d3a22ea consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/BftExtraData.java
|
||||||
3ab425b5b5f7d7c2199065a3905623d5c1c4c5922d892c6c4275415b0c69a2b4 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/FalconSeal.java
|
3ab425b5b5f7d7c2199065a3905623d5c1c4c5922d892c6c4275415b0c69a2b4 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/FalconSeal.java
|
||||||
db1e80115ce59c8281fbf046cb37e153a49fe017c1675afd200e8125d3f6b538 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/FalconSealScheme.java
|
db1e80115ce59c8281fbf046cb37e153a49fe017c1675afd200e8125d3f6b538 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/FalconSealScheme.java
|
||||||
167af0d87b017e1e15ecd2105426b009b6bdaeec5b52f7c1f710d212b0330765 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/FalconSealSupport.java
|
4b6de4abba87246b18c26fb18a65dfc68831aa981dffefe15eeb2c075fdfcf0f consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/FalconSealSupport.java
|
||||||
e8c8111a343cc993b950ac4e5b3992f16b8ee3d34c8827f00dd80d017884335b consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/HybridSealProducer.java
|
e8c8111a343cc993b950ac4e5b3992f16b8ee3d34c8827f00dd80d017884335b consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/HybridSealProducer.java
|
||||||
03043d2360c35de3b6f27d029b7ee83cb6c964f4b827a504799607b8bfe1cdcc consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/HybridSealSupport.java
|
03043d2360c35de3b6f27d029b7ee83cb6c964f4b827a504799607b8bfe1cdcc consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/HybridSealSupport.java
|
||||||
65e33e639c58adb30045e88c770460757305acab31ac884e4c6b36f1c410ef12 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/HybridSignerRegistry.java
|
65e33e639c58adb30045e88c770460757305acab31ac884e4c6b36f1c410ef12 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/HybridSignerRegistry.java
|
||||||
e9b30713dea69601dd29b3e460d81585a21f5c48286d18f4d3bc5d4c4ec83c19 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchor.java
|
afe13adaa495c4d0a9ada14df555afcbc6c8d46d8883730ab1ce201499eebc5f consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchor.java
|
||||||
852113d420f2c6b82945423a18591c3b2bd1018febe1a52e8b4e42a996c85114 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorConfig.java
|
852113d420f2c6b82945423a18591c3b2bd1018febe1a52e8b4e42a996c85114 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorConfig.java
|
||||||
c6285ca43331781e1d350a07d58256d02b8b3a74b96b6469c0dca33ecfb6c8cd consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorLapse.java
|
c6285ca43331781e1d350a07d58256d02b8b3a74b96b6469c0dca33ecfb6c8cd consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorLapse.java
|
||||||
f27952a25bad02d2939a356f1bac8800ff8fb08023c6bdfb1c6d0e51b5a206da consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorNotReadyException.java
|
f27952a25bad02d2939a356f1bac8800ff8fb08023c6bdfb1c6d0e51b5a206da consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorNotReadyException.java
|
||||||
@ -81,23 +81,28 @@ e0bc7a465dbb2dd7efa5f1830211a1a45c6554c300b2088dd70bb9677b0ac217 consensus/comm
|
|||||||
9a371ec367cda846640c6da18c8bf7d921a6882cfc024dc0c86d4f965cae0c63 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqStartupHistoryTest.java
|
9a371ec367cda846640c6da18c8bf7d921a6882cfc024dc0c86d4f965cae0c63 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqStartupHistoryTest.java
|
||||||
e65ba92e288aebe768909b1fee5c0b8850be74b3658c6242e6abdb91409a9521 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqV2Fixture.java
|
e65ba92e288aebe768909b1fee5c0b8850be74b3658c6242e6abdb91409a9521 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqV2Fixture.java
|
||||||
56426ba6bb10df29f5ef5f5959b1646dda5a6d9e7db9904ceb7ad1070ad750b8 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PreparePqAttachGateTest.java
|
56426ba6bb10df29f5ef5f5959b1646dda5a6d9e7db9904ceb7ad1070ad750b8 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PreparePqAttachGateTest.java
|
||||||
|
e355b147fdda48c14cb6dc09b1bc1e7a7ccae3f9b709af8f8483f896086dbe4d consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/ProposalPqAttachGateTest.java
|
||||||
d48d62c6ea0784864a5987a804a28855265b2180c409ba3c5b4434d21b805e1d consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/SealSchemeAgilityTest.java
|
d48d62c6ea0784864a5987a804a28855265b2180c409ba3c5b4434d21b805e1d consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/SealSchemeAgilityTest.java
|
||||||
22ade1aa2d8254d1f7ff02bbbcb4d02155546a52fca3421b61eea1129b73ee48 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/messagewrappers/Commit.java
|
22ade1aa2d8254d1f7ff02bbbcb4d02155546a52fca3421b61eea1129b73ee48 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/messagewrappers/Commit.java
|
||||||
046c67d18ba623cd01390b6ffef945b0dfa245fbf0cc79889b7670ab465b7992 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/network/QbftMessageTransmitter.java
|
046c67d18ba623cd01390b6ffef945b0dfa245fbf0cc79889b7670ab465b7992 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/network/QbftMessageTransmitter.java
|
||||||
03173f2029f767e10cc978b5576b9557efa783dab2ef105aa856f6d5e08b9352 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/CommitPayload.java
|
03173f2029f767e10cc978b5576b9557efa783dab2ef105aa856f6d5e08b9352 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/CommitPayload.java
|
||||||
37bda0f126c1333cf854a33c64cc98cedcdc8c0c2ec4cc3ba1f6736e7302ddff consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/MessageFactory.java
|
e577fe2c90ac2cf8bff2145376f44284cad1562c7d09b2eb6da9899849cdd882 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/MessageFactory.java
|
||||||
861a2e6f81df1c33755f28ca50bf88b6b0b197f7a7a1d646c165788d192160ca consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/PreparePayload.java
|
861a2e6f81df1c33755f28ca50bf88b6b0b197f7a7a1d646c165788d192160ca consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/PreparePayload.java
|
||||||
|
428e11639fb24ae4b04972457714445a5cd59d7946135a8b7362123461fa690f consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/ProposalPayload.java
|
||||||
64c3ab6f9b67eb2ed126bca89ffc0af49bebc17bc8a41b56efc19e51188ca75d consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/QbftBlockHeightManager.java
|
64c3ab6f9b67eb2ed126bca89ffc0af49bebc17bc8a41b56efc19e51188ca75d consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/QbftBlockHeightManager.java
|
||||||
252f9a86ca17cc6362780af264dbc4da4d842191467427a809ff1e4baa7640af consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/QbftController.java
|
252f9a86ca17cc6362780af264dbc4da4d842191467427a809ff1e4baa7640af consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/QbftController.java
|
||||||
07834160f12c4f28c567959c358176708b17742cc7cc4f49542b165efef5f1c8 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/QbftRound.java
|
a56a86a95e3ff7de3c9b22ee6e91c88ed713a3c065cadbb3b6fe205698a4c2c6 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/QbftRound.java
|
||||||
a22d857867c5b7c9ccdf9185c661870eafe6b481573189202b2cc34d9fbf3e2d consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/RoundState.java
|
a22d857867c5b7c9ccdf9185c661870eafe6b481573189202b2cc34d9fbf3e2d consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/RoundState.java
|
||||||
601b0cccf32f8ebe327c7581e0e9b3f9f67de49d9fb646326d6b49ef2138c641 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/types/QbftBlockCreator.java
|
601b0cccf32f8ebe327c7581e0e9b3f9f67de49d9fb646326d6b49ef2138c641 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/types/QbftBlockCreator.java
|
||||||
7f9fb13a7bbe3015bbef6329f750f2b35fb3744895cd3e6599af90450fab6a73 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/CommitValidator.java
|
7f9fb13a7bbe3015bbef6329f750f2b35fb3744895cd3e6599af90450fab6a73 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/CommitValidator.java
|
||||||
ec53f8e401a19345a067223f482f27a95cb9c3153d5bee0bfea9fd622bc0db01 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/PqCommitEnforcement.java
|
ec53f8e401a19345a067223f482f27a95cb9c3153d5bee0bfea9fd622bc0db01 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/PqCommitEnforcement.java
|
||||||
5ab52433f770537e838b15e23abfc7b24eaa733ea26577451d5e32226d617879 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/PqPrepareEnforcement.java
|
5ab52433f770537e838b15e23abfc7b24eaa733ea26577451d5e32226d617879 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/PqPrepareEnforcement.java
|
||||||
|
742611d52091a6c138cdff60c5f7f0bfb2fc33e567f520f3e86878fc086e77d9 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/PqProposalEnforcement.java
|
||||||
60eb9c5a8e3f464aa42915909e402bf59af9bad4e28631538dcf2f5ca52a9569 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/PrepareValidator.java
|
60eb9c5a8e3f464aa42915909e402bf59af9bad4e28631538dcf2f5ca52a9569 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/PrepareValidator.java
|
||||||
|
8ab72d5558842779a6df6d5d4d7c06479ac3a19746b51bdd00d5168084f0b590 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/ProposalPayloadValidator.java
|
||||||
36dd77cdea980e4de0090e86ae2ca75d1af62a9477fe056c7fdde06422617898 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/payload/CommitPayloadHybridTest.java
|
36dd77cdea980e4de0090e86ae2ca75d1af62a9477fe056c7fdde06422617898 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/payload/CommitPayloadHybridTest.java
|
||||||
fe25255de4b5fa6d86412fa4c48218180f549f0a4ffea5b5cc0a7d19a5be4cd9 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/payload/PreparePayloadPqTest.java
|
fe25255de4b5fa6d86412fa4c48218180f549f0a4ffea5b5cc0a7d19a5be4cd9 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/payload/PreparePayloadPqTest.java
|
||||||
|
a994d5638940ea90f37ed6f3ed5eebc24781707f2f4555c7a079d1251ddbf893 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/payload/ProposalPayloadPqTest.java
|
||||||
0bb2c1e31a7f29e9b07fb7030567746fc6d79460ed4739d5ecafac44501f7bb5 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/statemachine/PqLateSealSalvageTest.java
|
0bb2c1e31a7f29e9b07fb7030567746fc6d79460ed4739d5ecafac44501f7bb5 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/statemachine/PqLateSealSalvageTest.java
|
||||||
8c40173c7b248edec7c29ee9e049f9e269f4725b637bceb8734735fc73eb9ca2 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/CommitValidatorPqEnforcementTest.java
|
8c40173c7b248edec7c29ee9e049f9e269f4725b637bceb8734735fc73eb9ca2 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/CommitValidatorPqEnforcementTest.java
|
||||||
401c25f63abb248dfe60b69676a34ea255bb669b4bc58e45916f6eecea3cb870 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqCommitEnforcementTest.java
|
401c25f63abb248dfe60b69676a34ea255bb669b4bc58e45916f6eecea3cb870 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqCommitEnforcementTest.java
|
||||||
@ -105,7 +110,9 @@ c0853ed97c53d54951e25ad6d0b70c0299dedd859cc7c44da64e3751d0e0de33 consensus/qbft
|
|||||||
5e0bb0ecc77ffb06f232e1aa81cca6870e5455af7c2846ae54d5f477f1bbb88b consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqHybridEnforcementTest.java
|
5e0bb0ecc77ffb06f232e1aa81cca6870e5455af7c2846ae54d5f477f1bbb88b consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqHybridEnforcementTest.java
|
||||||
5a47d247d7bcda77b57f5c906c3cee1af826785416012fafe9cfa49d63671388 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqPrepareAgilityTest.java
|
5a47d247d7bcda77b57f5c906c3cee1af826785416012fafe9cfa49d63671388 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqPrepareAgilityTest.java
|
||||||
1939e33cc8ea81782e5e17d68e2b59c001e4207ed8ef07acd1d2a166a047dcb3 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqPrepareEnforcementTest.java
|
1939e33cc8ea81782e5e17d68e2b59c001e4207ed8ef07acd1d2a166a047dcb3 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqPrepareEnforcementTest.java
|
||||||
|
afbd5e820e5dabfbaa924be2730019cc133fcbd03095f9a8a4b783d8b5e79c90 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqProposalEnforcementTest.java
|
||||||
dc9f9e862a11f0135d26976176a1d1adac3e84fd5f1e06572d8727262cb3374f consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PrepareValidatorPqWiringTest.java
|
dc9f9e862a11f0135d26976176a1d1adac3e84fd5f1e06572d8727262cb3374f consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PrepareValidatorPqWiringTest.java
|
||||||
|
2c612868dfdd61d86cadc4ca9b34faacf4e43aca88878feb72ac140e1321ef5d consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/ProposalPayloadValidatorPqWiringTest.java
|
||||||
83dd971f66ed63103d09db5283240556bf1db1e1925c2a07808c7f59d47433ad consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/RoundChangeJustificationPqTest.java
|
83dd971f66ed63103d09db5283240556bf1db1e1925c2a07808c7f59d47433ad consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/RoundChangeJustificationPqTest.java
|
||||||
79dea5e85b968af696bf57d51d0de175a4c025537200402e3594a4a8290d1a29 consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/QbftBlockHeaderValidationRulesetFactory.java
|
79dea5e85b968af696bf57d51d0de175a4c025537200402e3594a4a8290d1a29 consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/QbftBlockHeaderValidationRulesetFactory.java
|
||||||
7d0a75818ac64b601b79c32b8289eedda8e2300dbb0f5e9186084f4e01b04d26 consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/QbftExtraDataCodec.java
|
7d0a75818ac64b601b79c32b8289eedda8e2300dbb0f5e9186084f4e01b04d26 consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/QbftExtraDataCodec.java
|
||||||
|
|||||||
@ -147,6 +147,8 @@ This matters more than the file list, so it is stated before it.
|
|||||||
| Legacy per-block Falcon rule (`aere.falcon.forkBlock`) | yes | **no** — retired at the anchor block |
|
| Legacy per-block Falcon rule (`aere.falcon.forkBlock`) | yes | **no** — retired at the anchor block |
|
||||||
| Post-quantum seal on PREPARE, emission | yes | **no** — no node sets the property |
|
| Post-quantum seal on PREPARE, emission | yes | **no** — no node sets the property |
|
||||||
| Post-quantum seal on PREPARE, enforcement | yes | **no** — no node sets the property |
|
| Post-quantum seal on PREPARE, enforcement | yes | **no** — no node sets the property |
|
||||||
|
| Post-quantum seal on the PROPOSAL, emission | yes | **no** — no node sets the property |
|
||||||
|
| Post-quantum seal on the PROPOSAL, enforcement | yes | **no** — no node sets the property |
|
||||||
| Hybrid Falcon + SLH-DSA certificate | yes | **no** — needs new keys, not generated |
|
| Hybrid Falcon + SLH-DSA certificate | yes | **no** — needs new keys, not generated |
|
||||||
|
|
||||||
Everything in the "no" rows is **disarmed by absence, not by a flag**: the properties that arm them
|
Everything in the "no" rows is **disarmed by absence, not by a flag**: the properties that arm them
|
||||||
@ -160,6 +162,16 @@ unsealed PREPAREs never reaches the prepared state, so it never sends COMMIT at
|
|||||||
it has no safety net during an activation, which is why it is not armed anywhere and why its
|
it has no safety net during an activation, which is why it is not armed anywhere and why its
|
||||||
activation height is a decision that has not been taken.
|
activation height is a decision that has not been taken.
|
||||||
|
|
||||||
|
The PROPOSAL layer (added 2026-08-30) closes the remaining hot-path gap, and its claim is stated
|
||||||
|
precisely because the imprecise version oversells: an adversary who breaks ECDSA cannot finalize
|
||||||
|
anything while the PREPARE layer is armed, since "prepared" needs a full quorum of PREPAREs. What
|
||||||
|
forged proposals could still do is OPEN rounds and waste them. With proposal enforcement armed, a
|
||||||
|
proposal without a valid Falcon seal from its own proposer does not open a round. The seal signs
|
||||||
|
its own domain over (chainId, height, round, digest), so an honest proposal seal cannot be replayed
|
||||||
|
as a vote nor a vote seal as a proposal - both directions are tested. What remains classical: the
|
||||||
|
ROUND-CHANGE message itself (its embedded justifications are already coupled to PREPARE
|
||||||
|
enforcement) and node-level devp2p authentication.
|
||||||
|
|
||||||
### The files
|
### The files
|
||||||
|
|
||||||
- `consensus/common/.../bft/` — the anchor itself: configuration, the digest, the seal cache and
|
- `consensus/common/.../bft/` — the anchor itself: configuration, the digest, the seal cache and
|
||||||
|
|||||||
@ -136,10 +136,14 @@ public final class FalconSealSupport {
|
|||||||
private static final String ANCHOR_SLOT =
|
private static final String ANCHOR_SLOT =
|
||||||
"0000000000000000000000000000000000000000000000000000000000000000";
|
"0000000000000000000000000000000000000000000000000000000000000000";
|
||||||
|
|
||||||
/** Cate sigilii a emis acest nod pe PREPARE. Vezi {@link #preparesSealed()}. */
|
/** How many seals this node emitted on its own PREPAREs. See {@link #preparesSealed()}. */
|
||||||
private final java.util.concurrent.atomic.AtomicLong preparesSealed =
|
private final java.util.concurrent.atomic.AtomicLong preparesSealed =
|
||||||
new java.util.concurrent.atomic.AtomicLong();
|
new java.util.concurrent.atomic.AtomicLong();
|
||||||
|
|
||||||
|
/** How many seals this node emitted on its own PROPOSALs since startup. */
|
||||||
|
private final java.util.concurrent.atomic.AtomicLong proposalsSealed =
|
||||||
|
new java.util.concurrent.atomic.AtomicLong();
|
||||||
|
|
||||||
private final boolean signingEnabled;
|
private final boolean signingEnabled;
|
||||||
private final int localIndex;
|
private final int localIndex;
|
||||||
private final FalconPrivateKeyParameters localPrivateKey;
|
private final FalconPrivateKeyParameters localPrivateKey;
|
||||||
@ -3465,9 +3469,10 @@ public final class FalconSealSupport {
|
|||||||
* Property naming the first height at which this node ATTACHES a post-quantum seal to its own
|
* Property naming the first height at which this node ATTACHES a post-quantum seal to its own
|
||||||
* PREPARE messages. Absent = never, which is the configuration of every node today.
|
* PREPARE messages. Absent = never, which is the configuration of every node today.
|
||||||
*
|
*
|
||||||
* <p>SEPARAT de {@code aere.falcon.attachBlock}, si separarea e obligatorie: daca emiterea pe
|
* <p>SEPARATE from {@code aere.falcon.attachBlock}, and the separation is mandatory: if PREPARE
|
||||||
* PREPARE ar porni odata cu cea pe commit, ridicarea binarului pe flota ar deveni o zi de flag.
|
* emission started together with commit emission, rolling the binary onto the fleet would become
|
||||||
* Asa, binarul poate sta luni de zile pe toate nodurile inainte ca vreunul sa emita ceva nou.
|
* a flag day. This way the binary can sit on every node for months before any of them emits
|
||||||
|
* anything new.
|
||||||
*/
|
*/
|
||||||
public static final String PREPARE_ATTACH_PROPERTY = "aere.pq.preparePq.attachBlock";
|
public static final String PREPARE_ATTACH_PROPERTY = "aere.pq.preparePq.attachBlock";
|
||||||
|
|
||||||
@ -3576,6 +3581,106 @@ public final class FalconSealSupport {
|
|||||||
return preparesSealed.get();
|
return preparesSealed.get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Property naming the first height at which this node ATTACHES a post-quantum seal to its own
|
||||||
|
* PROPOSAL messages. Absent = never, which is the configuration of every node today.
|
||||||
|
*
|
||||||
|
* <p>SEPARATE from both the commit and the PREPARE gates, same reason: three layers that arm on
|
||||||
|
* three independent decisions must not share a switch, or the day one is turned on becomes a flag
|
||||||
|
* day for the others.
|
||||||
|
*/
|
||||||
|
public static final String PROPOSAL_ATTACH_PROPERTY = "aere.pq.proposalPq.attachBlock";
|
||||||
|
|
||||||
|
/** Environment fallback for {@link #PROPOSAL_ATTACH_PROPERTY}. */
|
||||||
|
public static final String PROPOSAL_ATTACH_ENV = "AERE_PQ_PROPOSALPQ_ATTACHBLOCK";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The configured PROPOSAL attachment height, read fresh on every call.
|
||||||
|
*
|
||||||
|
* <p>Absent = {@link Long#MAX_VALUE}, i.e. never. A value that is PRESENT but unreadable REFUSES
|
||||||
|
* loudly instead of disarming - the anchor loader's lesson, once per gate, every gate.
|
||||||
|
*
|
||||||
|
* @return the height, or Long.MAX_VALUE when unset
|
||||||
|
*/
|
||||||
|
public static long proposalAttachBlock() {
|
||||||
|
final String raw = resolve(PROPOSAL_ATTACH_PROPERTY, PROPOSAL_ATTACH_ENV);
|
||||||
|
if (raw == null || raw.isBlank()) {
|
||||||
|
return Long.MAX_VALUE;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final long v = Long.parseLong(raw.trim());
|
||||||
|
if (v < 0) {
|
||||||
|
throw new NumberFormatException("negative");
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
} catch (final NumberFormatException e) {
|
||||||
|
throw new ActivationConfigException(
|
||||||
|
ActivationConfigException.Kind.SYNTAX,
|
||||||
|
"AERE-PQC-PROPOSAL-CONF-01",
|
||||||
|
"AERE PQ PROPOSAL: "
|
||||||
|
+ PROPOSAL_ATTACH_PROPERTY
|
||||||
|
+ " is set to '"
|
||||||
|
+ raw
|
||||||
|
+ "', which is not a non-negative block height. A node must REFUSE to start rather "
|
||||||
|
+ "than silently run with PROPOSAL attachment disarmed: a disarmed node looks exactly "
|
||||||
|
+ "like a correctly configured one until the day it matters.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign this node's own PROPOSAL, when the PROPOSAL attachment gate is open at this height.
|
||||||
|
*
|
||||||
|
* <p>Same three requirements as {@link #signPrepare}, for the same three reasons: a key, the
|
||||||
|
* commit-layer coverage conditions, and its own height. NEVER throws except for the strict
|
||||||
|
* configuration case: a signing failure is a log line and an empty value, because the ECDSA path
|
||||||
|
* must not be disturbed.
|
||||||
|
*
|
||||||
|
* @param blockNumber the height being proposed
|
||||||
|
* @param message the domain-separated PROPOSAL message (see PqAnchor.proposalMessage)
|
||||||
|
* @return the seal, or empty when any gate is shut
|
||||||
|
*/
|
||||||
|
public Optional<FalconSeal> signProposal(final long blockNumber, final Bytes32 message) {
|
||||||
|
if (!signingEnabled) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
if (!attachmentArmed(blockNumber)) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
if (blockNumber < proposalAttachBlock()) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
final java.util.Optional<byte[]> sig =
|
||||||
|
((FalconSealScheme) SealSchemes.FALCON_512).signWithParams(localPrivateKey, message.toArray());
|
||||||
|
if (sig.isEmpty()) {
|
||||||
|
LOG.warn("AERE PQ PROPOSAL: Falcon signing failed (ECDSA path unaffected)");
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
final long n = proposalsSealed.incrementAndGet();
|
||||||
|
// Same log-volume decision as at PREPARE: a proposal is at most one per round per proposer, so
|
||||||
|
// this is quieter by nature, but the counter still steps so a testnet has a number to count.
|
||||||
|
if (n == 1L) {
|
||||||
|
LOG.info(
|
||||||
|
"AERE PQ PROPOSAL: this node EMITTED its first post-quantum seal on a PROPOSAL, at "
|
||||||
|
+ "height {} (gate {}={}). From here on its proposals carry a seal.",
|
||||||
|
blockNumber,
|
||||||
|
PROPOSAL_ATTACH_PROPERTY,
|
||||||
|
proposalAttachBlock());
|
||||||
|
} else if (n % 500L == 0L) {
|
||||||
|
LOG.info("AERE PQ PROPOSAL: {} seals emitted on PROPOSALs since startup.", n);
|
||||||
|
}
|
||||||
|
return Optional.of(new FalconSeal(localIndex, Bytes.wrap(sig.get())));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many seals this node has emitted on its own PROPOSALs since startup. Exists so the
|
||||||
|
* coverage step can be measured, exactly as at PREPARE.
|
||||||
|
*
|
||||||
|
* @return the count
|
||||||
|
*/
|
||||||
|
public long proposalsSealed() {
|
||||||
|
return proposalsSealed.get();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verify a Falcon seal against the registry public key for its validator index. Never throws.
|
* Verify a Falcon seal against the registry public key for its validator index. Never throws.
|
||||||
*
|
*
|
||||||
|
|||||||
@ -102,10 +102,10 @@ public final class PqAnchor {
|
|||||||
* The PREPARE domain label. AERE PQ (2026-08-28).
|
* The PREPARE domain label. AERE PQ (2026-08-28).
|
||||||
*
|
*
|
||||||
* <p>SEPARATE FROM COMMIT, and the separation is a security requirement, not a matter of style.
|
* <p>SEPARATE FROM COMMIT, and the separation is a security requirement, not a matter of style.
|
||||||
* If a PREPARE seal signed the same bytes as a commit seal, an adversary could take the PREPARE
|
* If a PREPARE seal signed the same bytes as a commit seal, an adversary could take a PREPARE
|
||||||
* dat CINSTIT de un validator si sa il lipeasca pe un COMMIT falsificat: semnatura ar verifica,
|
* seal given HONESTLY by a validator and paste it onto a forged COMMIT: the signature would
|
||||||
* si chiar regula pusa sa apere commitul ar fi ocolita. Un singur sir schimbat in preimagine face
|
* verify, and the very rule meant to defend the commit would be bypassed. One changed string in
|
||||||
* cele doua semnaturi netransferabile.
|
* the preimage makes the two signatures non-transferable.
|
||||||
*/
|
*/
|
||||||
public static final String PREPARE_DOMAIN = "AERE-PQ-PREPARE-1";
|
public static final String PREPARE_DOMAIN = "AERE-PQ-PREPARE-1";
|
||||||
|
|
||||||
@ -113,6 +113,21 @@ public final class PqAnchor {
|
|||||||
public static final Bytes PREPARE_DOMAIN_BYTES =
|
public static final Bytes PREPARE_DOMAIN_BYTES =
|
||||||
Bytes.wrap(PREPARE_DOMAIN.getBytes(StandardCharsets.US_ASCII));
|
Bytes.wrap(PREPARE_DOMAIN.getBytes(StandardCharsets.US_ASCII));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The PROPOSAL domain label. AERE PQ (2026-08-30), the next hot-path step after PREPARE.
|
||||||
|
*
|
||||||
|
* <p>SEPARATE FROM BOTH PREPARE AND COMMIT, for the same non-transferability reason: a seal a
|
||||||
|
* proposer gives HONESTLY over its own proposal must not be usable as a vote. A proposal is an
|
||||||
|
* OFFER, not a vote - the design note of 2026-08-28 measures that "prepared" needs a full quorum
|
||||||
|
* of PREPAREs, proposer included - so its seal must never count as one. One changed string in the
|
||||||
|
* preimage is what enforces that at the cryptographic layer instead of by convention.
|
||||||
|
*/
|
||||||
|
public static final String PROPOSAL_DOMAIN = "AERE-PQ-PROPOSAL-1";
|
||||||
|
|
||||||
|
/** The proposal domain label as raw bytes. */
|
||||||
|
public static final Bytes PROPOSAL_DOMAIN_BYTES =
|
||||||
|
Bytes.wrap(PROPOSAL_DOMAIN.getBytes(StandardCharsets.US_ASCII));
|
||||||
|
|
||||||
/** Orders Falcon seals by their registry index, ascending. */
|
/** Orders Falcon seals by their registry index, ascending. */
|
||||||
public static final Comparator<FalconSeal> BY_INDEX =
|
public static final Comparator<FalconSeal> BY_INDEX =
|
||||||
Comparator.comparingInt(FalconSeal::getValidatorIndex);
|
Comparator.comparingInt(FalconSeal::getValidatorIndex);
|
||||||
@ -237,8 +252,8 @@ public final class PqAnchor {
|
|||||||
*
|
*
|
||||||
* <p>RUNDA E IN PREIMAGINE, spre deosebire de commit, si asta e al doilea lucru care nu se sare:
|
* <p>RUNDA E IN PREIMAGINE, spre deosebire de commit, si asta e al doilea lucru care nu se sare:
|
||||||
* doua PREPARE-uri ale aceluiasi bloc in runde diferite sunt doua afirmatii diferite, iar un
|
* doua PREPARE-uri ale aceluiasi bloc in runde diferite sunt doua afirmatii diferite, iar un
|
||||||
* a seal given in one round must not be movable into another. Without the round, a seal from
|
* a seal given in one round must not be movable into another. Without the round, a seal from a
|
||||||
* PREPARE dintr-o runda esuata ar putea fi refolosit ca sa justifice o alta.
|
* PREPARE of a failed round could be reused to justify another one.
|
||||||
*
|
*
|
||||||
* @param chainId the chain id
|
* @param chainId the chain id
|
||||||
* @param blockNumber the height being prepared
|
* @param blockNumber the height being prepared
|
||||||
@ -273,6 +288,46 @@ public final class PqAnchor {
|
|||||||
return Hash.keccak256(out.encoded());
|
return Hash.keccak256(out.encoded());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The 32-byte message a proposer's post-quantum PROPOSAL seal signs.
|
||||||
|
*
|
||||||
|
* <p>Same shape as {@link #prepareMessage}, different domain, and the ROUND is in the preimage
|
||||||
|
* for the same reason: a proposal for the same block in a different round is a different
|
||||||
|
* assertion, and a seal from a failed round must not open another one.
|
||||||
|
*
|
||||||
|
* @param chainId the chain id
|
||||||
|
* @param blockNumber the height being proposed
|
||||||
|
* @param round the round number of the proposal
|
||||||
|
* @param digest the digest of the proposed block
|
||||||
|
* @return the 32-byte message to sign
|
||||||
|
*/
|
||||||
|
public static Bytes32 proposalMessage(
|
||||||
|
final long chainId, final long blockNumber, final int round, final Bytes digest) {
|
||||||
|
if (blockNumber < 0) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"AERE PQ PROPOSAL: blockNumber must not be negative (got " + blockNumber + ")");
|
||||||
|
}
|
||||||
|
if (round < 0) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"AERE PQ PROPOSAL: round must not be negative (got " + round + ")");
|
||||||
|
}
|
||||||
|
if (digest == null || digest.size() != 32) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"AERE PQ PROPOSAL: digest must be 32 bytes (got "
|
||||||
|
+ (digest == null ? "null" : digest.size() + " bytes")
|
||||||
|
+ ")");
|
||||||
|
}
|
||||||
|
final BytesValueRLPOutput out = new BytesValueRLPOutput();
|
||||||
|
out.startList();
|
||||||
|
out.writeBytes(PROPOSAL_DOMAIN_BYTES);
|
||||||
|
out.writeLongScalar(chainId);
|
||||||
|
out.writeLongScalar(blockNumber);
|
||||||
|
out.writeLongScalar(round);
|
||||||
|
out.writeBytes(digest);
|
||||||
|
out.endList();
|
||||||
|
return Hash.keccak256(out.encoded());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the certificate's validator indices are STRICTLY increasing.
|
* Whether the certificate's validator indices are STRICTLY increasing.
|
||||||
*
|
*
|
||||||
|
|||||||
@ -0,0 +1,104 @@
|
|||||||
|
/*
|
||||||
|
* Copyright contributors to Besu.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
|
||||||
|
* the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
|
||||||
|
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations under the License.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
package org.hyperledger.besu.consensus.common.bft;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
import org.apache.tuweni.bytes.Bytes32;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE GATE that emits a seal on the PROPOSAL (AERE PQ, 2026-08-30). Twin of the PREPARE gate,
|
||||||
|
* with the same shape on purpose.
|
||||||
|
*
|
||||||
|
* <p>What is measured here is the CONFIGURATION SURFACE, which is exactly the part that gets typed
|
||||||
|
* by hand and therefore mistyped: absent means never, a good value means from that height onwards,
|
||||||
|
* and a MISTYPED value refuses loudly instead of booting the node disarmed. The lesson paid for in
|
||||||
|
* the anchor loader is that a stray character must never disarm silently, because then nobody finds
|
||||||
|
* out.
|
||||||
|
*
|
||||||
|
* <p>What is NOT measured here, and it is said plainly: that an ARMED node actually produces a
|
||||||
|
* seal. That needs a Falcon key and a registry bound to addresses, which means a network; it is
|
||||||
|
* measured at the coverage step, on a testnet. What is proven here is that the gate is closed by
|
||||||
|
* default and cannot be opened by accident.
|
||||||
|
*/
|
||||||
|
class ProposalPqAttachGateTest {
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void clearTheProperty() {
|
||||||
|
System.clearProperty(FalconSealSupport.PROPOSAL_ATTACH_PROPERTY);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void withoutThePropertyTheGateIsClosedForever() {
|
||||||
|
assertThat(FalconSealSupport.proposalAttachBlock()).isEqualTo(Long.MAX_VALUE);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aGoodValueIsReadAsGiven() {
|
||||||
|
System.setProperty(FalconSealSupport.PROPOSAL_ATTACH_PROPERTY, "16500000");
|
||||||
|
assertThat(FalconSealSupport.proposalAttachBlock()).isEqualTo(16_500_000L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void zeroIsALEGALValue() {
|
||||||
|
// A threshold of zero means "from genesis", and that is a legitimate configuration on a
|
||||||
|
// testnet. Treated as "unset", a correctly configured testnet would run disarmed in silence.
|
||||||
|
System.setProperty(FalconSealSupport.PROPOSAL_ATTACH_PROPERTY, "0");
|
||||||
|
assertThat(FalconSealSupport.proposalAttachBlock()).isZero();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aMISTYPEDValueRefusesLoudly() {
|
||||||
|
for (final String bad : new String[] {"nu-e-numar", "16_500_000", "1e6", "-1", " "}) {
|
||||||
|
System.setProperty(FalconSealSupport.PROPOSAL_ATTACH_PROPERTY, bad);
|
||||||
|
if (bad.isBlank()) {
|
||||||
|
// whitespace is "unset", not a mistyped value: an empty field in a configuration file
|
||||||
|
// must not stop a node
|
||||||
|
assertThat(FalconSealSupport.proposalAttachBlock()).isEqualTo(Long.MAX_VALUE);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
assertThatThrownBy(FalconSealSupport::proposalAttachBlock)
|
||||||
|
.as("the value '%s'", bad)
|
||||||
|
.isInstanceOf(FalconSealSupport.ActivationConfigException.class)
|
||||||
|
.hasMessageContaining("AERE-PQC-PROPOSAL-CONF-01");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void withNoKeyNothingIsSignedEvenWithTheGateOpen() {
|
||||||
|
// The gate is open from genesis and still nothing comes out: the node has no Falcon key. That
|
||||||
|
// is precisely the condition that makes the binary safe to roll onto the fleet before any
|
||||||
|
// decision is taken.
|
||||||
|
System.setProperty(FalconSealSupport.PROPOSAL_ATTACH_PROPERTY, "0");
|
||||||
|
assertThat(FalconSealSupport.instance().signProposal(1L, Bytes32.ZERO)).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void thePROPOSALGateIsNotThePREPAREOrCOMMITGate() {
|
||||||
|
// Three layers, three switches. If any two shared one, the day one is turned on would become a
|
||||||
|
// flag day for the other - and commit emission is already on since block 13,889,296 on 2800.
|
||||||
|
assertThat(FalconSealSupport.PROPOSAL_ATTACH_PROPERTY).isNotEqualTo("aere.falcon.attachBlock");
|
||||||
|
assertThat(FalconSealSupport.PROPOSAL_ATTACH_PROPERTY)
|
||||||
|
.isNotEqualTo(FalconSealSupport.PREPARE_ATTACH_PROPERTY);
|
||||||
|
System.setProperty(FalconSealSupport.PROPOSAL_ATTACH_PROPERTY, "16500000");
|
||||||
|
assertThat(FalconSealSupport.proposalAttachBlock()).isEqualTo(16_500_000L);
|
||||||
|
// the commit and PREPARE properties stay untouched by the PROPOSAL one
|
||||||
|
assertThat(System.getProperty("aere.falcon.attachBlock")).isNull();
|
||||||
|
assertThat(System.getProperty(FalconSealSupport.PREPARE_ATTACH_PROPERTY)).isNull();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -82,6 +82,35 @@ public class MessageFactory {
|
|||||||
return new Proposal(createSignedMessage(payload), roundChanges, prepares);
|
return new Proposal(createSignedMessage(payload), roundChanges, prepares);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create proposal carrying the proposer's optional post-quantum seal (AERE PQ, 2026-08-30).
|
||||||
|
*
|
||||||
|
* <p>The seal rides INSIDE the signed payload, so the proposer's ECDSA signature covers it and
|
||||||
|
* nobody can strip or replace it without failing authorship recovery. With the seal absent this
|
||||||
|
* overload produces byte-for-byte what the upstream overload produces.
|
||||||
|
*
|
||||||
|
* @param roundIdentifier the round identifier
|
||||||
|
* @param block the block
|
||||||
|
* @param blockAccessList the block access list
|
||||||
|
* @param roundChanges the round changes
|
||||||
|
* @param prepares the prepares
|
||||||
|
* @param falconSeal the proposer's post-quantum seal, or empty
|
||||||
|
* @return the proposal
|
||||||
|
*/
|
||||||
|
public Proposal createProposal(
|
||||||
|
final ConsensusRoundIdentifier roundIdentifier,
|
||||||
|
final QbftBlock block,
|
||||||
|
final Optional<BlockAccessList> blockAccessList,
|
||||||
|
final List<SignedData<RoundChangePayload>> roundChanges,
|
||||||
|
final List<SignedData<PreparePayload>> prepares,
|
||||||
|
final Optional<FalconSeal> falconSeal) {
|
||||||
|
|
||||||
|
final ProposalPayload payload =
|
||||||
|
new ProposalPayload(roundIdentifier, block, blockEncoder, blockAccessList, falconSeal);
|
||||||
|
|
||||||
|
return new Proposal(createSignedMessage(payload), roundChanges, prepares);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create proposal.
|
* Create proposal.
|
||||||
*
|
*
|
||||||
|
|||||||
@ -0,0 +1,254 @@
|
|||||||
|
/*
|
||||||
|
* Copyright ConsenSys AG.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
|
||||||
|
* the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
|
||||||
|
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations under the License.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
package org.hyperledger.besu.consensus.qbft.core.payload;
|
||||||
|
|
||||||
|
import org.hyperledger.besu.consensus.common.bft.ConsensusRoundIdentifier;
|
||||||
|
import org.hyperledger.besu.consensus.common.bft.FalconSeal;
|
||||||
|
import org.hyperledger.besu.consensus.qbft.core.messagedata.QbftV1;
|
||||||
|
import org.hyperledger.besu.consensus.qbft.core.types.QbftBlock;
|
||||||
|
import org.hyperledger.besu.consensus.qbft.core.types.QbftBlockCodec;
|
||||||
|
import org.hyperledger.besu.ethereum.core.encoding.BlockAccessListDecoder;
|
||||||
|
import org.hyperledger.besu.ethereum.mainnet.block.access.list.BlockAccessList;
|
||||||
|
import org.hyperledger.besu.ethereum.rlp.RLPException;
|
||||||
|
import org.hyperledger.besu.ethereum.rlp.RLPInput;
|
||||||
|
import org.hyperledger.besu.ethereum.rlp.RLPOutput;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import com.google.common.base.MoreObjects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Proposal payload.
|
||||||
|
*
|
||||||
|
* <p>AERE PQ (2026-08-30), the hot-path step after PREPARE: a PROPOSAL MAY carry an OPTIONAL
|
||||||
|
* Falcon-512 seal from its proposer, appended after the block-access-list element, exactly as
|
||||||
|
* {@code PreparePayload} appends its seal. A proposal without a seal encodes <b>byte for byte as
|
||||||
|
* upstream</b>, which is the property that lets the binary be rolled onto a live fleet without a
|
||||||
|
* flag day.
|
||||||
|
*
|
||||||
|
* <p><b>NOTHING EMITS SUCH A PROPOSAL YET.</b> This file only makes the wire capable of carrying
|
||||||
|
* one and of refusing a malformed one. Emission has its own gate
|
||||||
|
* ({@code aere.pq.proposalPq.attachBlock}); enforcement has its own height
|
||||||
|
* ({@code aere.pq.proposalPq.forkBlock}); both are absent on every node today, and absent means
|
||||||
|
* never.
|
||||||
|
*
|
||||||
|
* <p><b>What the seal signs is NOT this file's business:</b> its own domain
|
||||||
|
* {@code AERE-PQ-PROPOSAL-1} over (chainId, height, ROUND, digest) - see
|
||||||
|
* {@code PqAnchor.proposalMessage}. A proposal is an offer, not a vote: with the commit or PREPARE
|
||||||
|
* domain, a proposal seal given honestly could be pasted onto a forged vote and counted.
|
||||||
|
*
|
||||||
|
* <p><b>Why the trailing element is parsed strictly:</b> the payload is AUTHENTICATED - the author
|
||||||
|
* is recovered from the signature over the encoded payload. A decoder that silently ignored an
|
||||||
|
* unknown trailing element would re-encode without it, so two different byte strings would
|
||||||
|
* authenticate to the same proposer. When a fifth element is present it must be exactly a seal,
|
||||||
|
* and it must be the last element; otherwise the message is refused. Messages of the upstream
|
||||||
|
* shapes (three or four elements) are read exactly as upstream reads them.
|
||||||
|
*/
|
||||||
|
public class ProposalPayload extends QbftPayload {
|
||||||
|
|
||||||
|
private static final int TYPE = QbftV1.PROPOSAL;
|
||||||
|
private final ConsensusRoundIdentifier roundIdentifier;
|
||||||
|
private final QbftBlock proposedBlock;
|
||||||
|
private final QbftBlockCodec blockEncoder;
|
||||||
|
private final Optional<BlockAccessList> blockAccessList;
|
||||||
|
private final Optional<FalconSeal> falconSeal;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instantiates a new Proposal payload carrying an optional post-quantum seal of its proposer.
|
||||||
|
*
|
||||||
|
* @param roundIdentifier the round identifier
|
||||||
|
* @param proposedBlock the proposed block
|
||||||
|
* @param blockEncoder the qbft block encoder
|
||||||
|
* @param blockAccessList the block access list
|
||||||
|
* @param falconSeal the proposer's post-quantum seal, or empty
|
||||||
|
*/
|
||||||
|
public ProposalPayload(
|
||||||
|
final ConsensusRoundIdentifier roundIdentifier,
|
||||||
|
final QbftBlock proposedBlock,
|
||||||
|
final QbftBlockCodec blockEncoder,
|
||||||
|
final Optional<BlockAccessList> blockAccessList,
|
||||||
|
final Optional<FalconSeal> falconSeal) {
|
||||||
|
this.roundIdentifier = roundIdentifier;
|
||||||
|
this.proposedBlock = proposedBlock;
|
||||||
|
this.blockEncoder = blockEncoder;
|
||||||
|
this.blockAccessList = blockAccessList;
|
||||||
|
this.falconSeal = falconSeal == null ? Optional.empty() : falconSeal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instantiates a new Proposal payload, without a post-quantum seal. Encodes byte-for-byte as
|
||||||
|
* upstream Besu.
|
||||||
|
*
|
||||||
|
* @param roundIdentifier the round identifier
|
||||||
|
* @param proposedBlock the proposed block
|
||||||
|
* @param blockEncoder the qbft block encoder
|
||||||
|
* @param blockAccessList the block access list
|
||||||
|
*/
|
||||||
|
public ProposalPayload(
|
||||||
|
final ConsensusRoundIdentifier roundIdentifier,
|
||||||
|
final QbftBlock proposedBlock,
|
||||||
|
final QbftBlockCodec blockEncoder,
|
||||||
|
final Optional<BlockAccessList> blockAccessList) {
|
||||||
|
this(roundIdentifier, proposedBlock, blockEncoder, blockAccessList, Optional.empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instantiates a new Proposal payload.
|
||||||
|
*
|
||||||
|
* @param roundIdentifier the round identifier
|
||||||
|
* @param proposedBlock the proposed block
|
||||||
|
* @param blockEncoder the qbft block encoder
|
||||||
|
*/
|
||||||
|
public ProposalPayload(
|
||||||
|
final ConsensusRoundIdentifier roundIdentifier,
|
||||||
|
final QbftBlock proposedBlock,
|
||||||
|
final QbftBlockCodec blockEncoder) {
|
||||||
|
this(roundIdentifier, proposedBlock, blockEncoder, Optional.empty(), Optional.empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read from rlp input and return proposal payload.
|
||||||
|
*
|
||||||
|
* @param rlpInput the rlp input
|
||||||
|
* @param blockEncoder the qbft block encoder
|
||||||
|
* @return the proposal payload
|
||||||
|
*/
|
||||||
|
public static ProposalPayload readFrom(
|
||||||
|
final RLPInput rlpInput, final QbftBlockCodec blockEncoder) {
|
||||||
|
rlpInput.enterList();
|
||||||
|
final ConsensusRoundIdentifier roundIdentifier = readConsensusRound(rlpInput);
|
||||||
|
final QbftBlock proposedBlock = blockEncoder.readFrom(rlpInput);
|
||||||
|
final Optional<BlockAccessList> blockAccessList = readBlockAccessList(rlpInput);
|
||||||
|
|
||||||
|
// AERE PQ: the OPTIONAL proposer seal [index, signature]. A proposal without one ends the list
|
||||||
|
// here and decodes to Optional.empty(), so the upstream shapes stay untouched. When a fifth
|
||||||
|
// element exists it must be a seal and it must be last: an unknown trailing element on an
|
||||||
|
// authenticated payload is malleability, not extensibility.
|
||||||
|
Optional<FalconSeal> falconSeal = Optional.empty();
|
||||||
|
if (!rlpInput.isEndOfCurrentList()) {
|
||||||
|
rlpInput.enterList();
|
||||||
|
final int idx = rlpInput.readIntScalar();
|
||||||
|
final org.apache.tuweni.bytes.Bytes sig = rlpInput.readBytes();
|
||||||
|
rlpInput.leaveList();
|
||||||
|
falconSeal = Optional.of(new FalconSeal(idx, sig));
|
||||||
|
if (!rlpInput.isEndOfCurrentList()) {
|
||||||
|
throw new RLPException(
|
||||||
|
"Proposal payload carries elements after the proposer seal; refusing an encoding the "
|
||||||
|
+ "re-encoder would silently drop");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rlpInput.leaveList();
|
||||||
|
|
||||||
|
return new ProposalPayload(
|
||||||
|
roundIdentifier, proposedBlock, blockEncoder, blockAccessList, falconSeal);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void writeTo(final RLPOutput rlpOutput) {
|
||||||
|
rlpOutput.startList();
|
||||||
|
writeConsensusRound(rlpOutput);
|
||||||
|
blockEncoder.writeTo(proposedBlock, rlpOutput);
|
||||||
|
blockAccessList.ifPresentOrElse((bal) -> bal.writeTo(rlpOutput), rlpOutput::writeNull);
|
||||||
|
// The seal is written only when present, so a seal-less proposal is identical to upstream.
|
||||||
|
if (falconSeal.isPresent()) {
|
||||||
|
final FalconSeal fs = falconSeal.get();
|
||||||
|
rlpOutput.startList();
|
||||||
|
rlpOutput.writeIntScalar(fs.getValidatorIndex());
|
||||||
|
rlpOutput.writeBytes(fs.getSignature());
|
||||||
|
rlpOutput.endList();
|
||||||
|
}
|
||||||
|
rlpOutput.endList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets proposed block.
|
||||||
|
*
|
||||||
|
* @return the proposed block
|
||||||
|
*/
|
||||||
|
public QbftBlock getProposedBlock() {
|
||||||
|
return proposedBlock;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets block access list.
|
||||||
|
*
|
||||||
|
* @return the block access list
|
||||||
|
*/
|
||||||
|
public Optional<BlockAccessList> getBlockAccessList() {
|
||||||
|
return blockAccessList;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The proposer's post-quantum seal, when the message carries one.
|
||||||
|
*
|
||||||
|
* @return the seal, or empty
|
||||||
|
*/
|
||||||
|
public Optional<FalconSeal> getFalconSeal() {
|
||||||
|
return falconSeal;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getMessageType() {
|
||||||
|
return TYPE;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ConsensusRoundIdentifier getRoundIdentifier() {
|
||||||
|
return roundIdentifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean equals(final Object o) {
|
||||||
|
if (this == o) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (o == null || getClass() != o.getClass()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
ProposalPayload that = (ProposalPayload) o;
|
||||||
|
return Objects.equals(roundIdentifier, that.roundIdentifier)
|
||||||
|
&& Objects.equals(proposedBlock, that.proposedBlock)
|
||||||
|
&& Objects.equals(blockAccessList, that.blockAccessList)
|
||||||
|
&& Objects.equals(falconSeal, that.falconSeal);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
return Objects.hash(roundIdentifier, proposedBlock, blockAccessList, falconSeal);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return MoreObjects.toStringHelper(this)
|
||||||
|
.add("roundIdentifier", roundIdentifier)
|
||||||
|
.add("proposedBlock", proposedBlock)
|
||||||
|
.add("blockAccessList", blockAccessList)
|
||||||
|
.add("falconSeal", falconSeal.isPresent() ? "present" : "absent")
|
||||||
|
.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Optional<BlockAccessList> readBlockAccessList(final RLPInput rlpInput) {
|
||||||
|
if (rlpInput.isEndOfCurrentList()) {
|
||||||
|
// Backward compatibility: pre-26.1.0 messages do not include blockAccessList
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
if (!rlpInput.nextIsNull()) {
|
||||||
|
return Optional.of(BlockAccessListDecoder.decode(rlpInput));
|
||||||
|
}
|
||||||
|
rlpInput.skipNext();
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -244,8 +244,17 @@ public class QbftRound {
|
|||||||
final List<SignedData<PreparePayload>> prepares) {
|
final List<SignedData<PreparePayload>> prepares) {
|
||||||
final Proposal proposal;
|
final Proposal proposal;
|
||||||
try {
|
try {
|
||||||
|
// AERE PQ PROPOSAL (2026-08-30): the seal is computed once, here, and rides INSIDE the
|
||||||
|
// signed payload. With the gate closed - every node today - the seal is empty and the OLD
|
||||||
|
// overload is taken call for call, so a node that emits nothing new behaves identically to
|
||||||
|
// one from before this change. Same pattern as PREPARE and commit, deliberately.
|
||||||
|
final Optional<FalconSeal> proposalSeal = proposalSealFor(block);
|
||||||
proposal =
|
proposal =
|
||||||
messageFactory.createProposal(
|
proposalSeal.isPresent()
|
||||||
|
? messageFactory.createProposal(
|
||||||
|
getRoundIdentifier(), block, blockAccessList, roundChanges, prepares,
|
||||||
|
proposalSeal)
|
||||||
|
: messageFactory.createProposal(
|
||||||
getRoundIdentifier(), block, blockAccessList, roundChanges, prepares);
|
getRoundIdentifier(), block, blockAccessList, roundChanges, prepares);
|
||||||
} catch (final SecurityModuleException e) {
|
} catch (final SecurityModuleException e) {
|
||||||
LOG.warn("Failed to create a signed Proposal, waiting for next round.", e);
|
LOG.warn("Failed to create a signed Proposal, waiting for next round.", e);
|
||||||
@ -283,16 +292,16 @@ public class QbftRound {
|
|||||||
LOG.debug("Sending prepare message. round={}", roundState.getRoundIdentifier());
|
LOG.debug("Sending prepare message. round={}", roundState.getRoundIdentifier());
|
||||||
try {
|
try {
|
||||||
// AERE PQ PREPARE (2026-08-28), step 2: the seal is computed EXACTLY ONCE here and is
|
// AERE PQ PREPARE (2026-08-28), step 2: the seal is computed EXACTLY ONCE here and is
|
||||||
// trece si exemplarului local si celui de pe fir. Semnaturile Falcon sunt randomizate, deci
|
// handed to both the local copy and the one on the wire. Falcon signatures are randomised,
|
||||||
// doua semnari ale aceluiasi mesaj dau doi octeti diferiti; daca fiecare exemplar si-ar semna
|
// so two signings of the same message give two different byte strings; if each copy signed
|
||||||
// its own, the same validator would produce two valid and DIFFERENT PREPAREs for the same
|
// its own, the same validator would produce two valid and DIFFERENT PREPAREs for the same
|
||||||
// round. The gate is closed on every node today, so this is empty until a decision.
|
// round. The gate is closed on every node today, so this is empty until a decision.
|
||||||
final Optional<FalconSeal> falconSeal = prepareSealFor(block);
|
final Optional<FalconSeal> falconSeal = prepareSealFor(block);
|
||||||
// WITH THE GATE CLOSED the OLD path is taken, call for call. This is not style: the upstream
|
// WITH THE GATE CLOSED the OLD path is taken, call for call. This is not style: the upstream
|
||||||
// tests assert exactly the two-argument call, and more importantly a node that emits nothing new
|
// tests assert exactly the two-argument call, and more importantly a node that emits nothing
|
||||||
// trebuie sa se comporte identic cu unul de dinaintea acestei schimbari - nu doar sa scrie
|
// new must behave identically to one from before this change - not merely write the same
|
||||||
// aceiasi octeti, ci sa treaca prin aceleasi apeluri. Asa, binarul poate sta pe flota fara
|
// bytes, but go through the same calls. That way the binary can sit on the fleet with
|
||||||
// so that nothing changes until a decision is made. Same pattern as commit.
|
// nothing changing until a decision is made. Same pattern as commit.
|
||||||
final Prepare localPrepareMessage =
|
final Prepare localPrepareMessage =
|
||||||
falconSeal.isPresent()
|
falconSeal.isPresent()
|
||||||
? messageFactory.createPrepare(getRoundIdentifier(), block.getHash(), falconSeal)
|
? messageFactory.createPrepare(getRoundIdentifier(), block.getHash(), falconSeal)
|
||||||
@ -311,11 +320,11 @@ public class QbftRound {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sigiliul post-cuantic al acestui nod pentru PREPARE-ul blocului dat, sau gol.
|
* This node's post-quantum seal for the PREPARE of the given block, or empty.
|
||||||
*
|
*
|
||||||
* <p>Mesajul semnat are DOMENIUL LUI si contine RUNDA - vezi PqAnchor.prepareMessage si
|
* <p>The signed message has ITS OWN DOMAIN and contains the ROUND - see PqAnchor.prepareMessage
|
||||||
* proiectarea din 2026-08-28. Cu domeniul commitului, un sigiliu de PREPARE dat cinstit ar putea
|
* and the 2026-08-28 design note. Under the commit domain, a PREPARE seal given honestly could
|
||||||
* fi lipit pe un COMMIT falsificat.
|
* be pasted onto a forged COMMIT.
|
||||||
*/
|
*/
|
||||||
private Optional<FalconSeal> prepareSealFor(final QbftBlock block) {
|
private Optional<FalconSeal> prepareSealFor(final QbftBlock block) {
|
||||||
final long blockNumber = block.getHeader().getNumber();
|
final long blockNumber = block.getHeader().getNumber();
|
||||||
@ -328,6 +337,24 @@ public class QbftRound {
|
|||||||
return FalconSealSupport.instance().signPrepare(blockNumber, message);
|
return FalconSealSupport.instance().signPrepare(blockNumber, message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This node's post-quantum seal for its own PROPOSAL of the given block, or empty.
|
||||||
|
*
|
||||||
|
* <p>Its own domain again - see PqAnchor.proposalMessage. A proposal is an offer, not a vote:
|
||||||
|
* under the PREPARE or commit domain, a proposal seal given honestly could be pasted onto a
|
||||||
|
* forged vote and counted.
|
||||||
|
*/
|
||||||
|
private Optional<FalconSeal> proposalSealFor(final QbftBlock block) {
|
||||||
|
final long blockNumber = block.getHeader().getNumber();
|
||||||
|
final Bytes32 message =
|
||||||
|
PqAnchor.proposalMessage(
|
||||||
|
PqAnchorProducer.config().chainId(),
|
||||||
|
blockNumber,
|
||||||
|
getRoundIdentifier().getRoundNumber(),
|
||||||
|
block.getHash().getBytes());
|
||||||
|
return FalconSealSupport.instance().signProposal(blockNumber, message);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle prepare message.
|
* Handle prepare message.
|
||||||
*
|
*
|
||||||
|
|||||||
@ -0,0 +1,202 @@
|
|||||||
|
/*
|
||||||
|
* Copyright contributors to Besu.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
|
||||||
|
* the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
|
||||||
|
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations under the License.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
package org.hyperledger.besu.consensus.qbft.core.validation;
|
||||||
|
|
||||||
|
import org.hyperledger.besu.consensus.common.bft.FalconSeal;
|
||||||
|
import org.hyperledger.besu.consensus.common.bft.PqAnchor;
|
||||||
|
import org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer;
|
||||||
|
import org.hyperledger.besu.consensus.common.bft.PqSignerRegistry;
|
||||||
|
import org.hyperledger.besu.datatypes.Address;
|
||||||
|
import org.hyperledger.besu.datatypes.Hash;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import org.apache.tuweni.bytes.Bytes32;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST-QUANTUM ENFORCEMENT ON THE PROPOSAL. The hot-path step after PREPARE (AERE PQ, 2026-08-30).
|
||||||
|
*
|
||||||
|
* <p>From the armed height onwards, a PROPOSAL is not accepted without a valid post-quantum seal
|
||||||
|
* from its OWN proposer. The structure copies {@link PqProposalEnforcement} line for line on
|
||||||
|
* purpose: a second rendering of the same idea, written differently, diverges eventually.
|
||||||
|
*
|
||||||
|
* <p><b>WHAT THIS LAYER BUYS, said precisely.</b> A proposal is an OFFER, not a vote: the design
|
||||||
|
* note of 2026-08-28 measures that "prepared" needs a full quorum of PREPAREs, proposer included,
|
||||||
|
* so an adversary who breaks ECDSA and forges proposals still cannot finalize anything while the
|
||||||
|
* PREPARE layer is armed. What forged proposals CAN do is start rounds and waste them - steering
|
||||||
|
* which honest proposals get considered and degrading liveness. This layer closes that: a proposal
|
||||||
|
* whose proposer cannot produce a Falcon seal does not even open a round on an armed node.
|
||||||
|
*
|
||||||
|
* <p><b>WHAT THE SEAL SIGNS.</b> Its own domain, {@code AERE-PQ-PROPOSAL-1}, over (chainId,
|
||||||
|
* height, ROUND, digest). Under the PREPARE or commit domain, a proposal seal given honestly could
|
||||||
|
* be pasted onto a forged vote and counted; one changed string in the preimage makes the
|
||||||
|
* signatures non-transferable in both directions.
|
||||||
|
*
|
||||||
|
* <p><b>DISARMED BY DEFAULT.</b> Without the property, {@link #fromSystemConfig()} returns null and
|
||||||
|
* the validator behaves exactly as upstream. A value that is PRESENT but unreadable REFUSES loudly:
|
||||||
|
* a node that boots disarmed because of a mistyped character looks exactly like a correctly
|
||||||
|
* configured one, right up to the day it matters.
|
||||||
|
*/
|
||||||
|
public final class PqProposalEnforcement {
|
||||||
|
|
||||||
|
/** The height nothing ever reaches: disarmed. */
|
||||||
|
public static final long DISARMED = Long.MAX_VALUE;
|
||||||
|
|
||||||
|
/** The property that arms PROPOSAL enforcement. */
|
||||||
|
public static final String PROPERTY_FORK_BLOCK = "aere.pq.proposalPq.forkBlock";
|
||||||
|
|
||||||
|
/** The equivalent environment variable. */
|
||||||
|
public static final String ENV_FORK_BLOCK = "AERE_PQ_PROPOSALPQ_FORKBLOCK";
|
||||||
|
|
||||||
|
private final long armedFromBlock;
|
||||||
|
private final PqSignerRegistry registry;
|
||||||
|
private final long chainId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param armedFromBlock first height (inclusive) at which enforcement applies; {@link #DISARMED}
|
||||||
|
* for today's behaviour
|
||||||
|
* @param registry the signer registry, injected, never a singleton
|
||||||
|
* @param chainId the chain that goes into the signed message
|
||||||
|
*/
|
||||||
|
public PqProposalEnforcement(
|
||||||
|
final long armedFromBlock, final PqSignerRegistry registry, final long chainId) {
|
||||||
|
this.armedFromBlock = armedFromBlock;
|
||||||
|
this.registry = registry;
|
||||||
|
this.chainId = chainId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same enforcement, with the chain id taken from the anchor configuration.
|
||||||
|
*
|
||||||
|
* <p>THE CHAIN ID IS AN ARGUMENT, NOT A GLOBAL, and its own test caught that: the first version
|
||||||
|
* read it from {@code PqAnchorProducer.config()} in the middle of a consensus decision, so the
|
||||||
|
* test signed over 2800 while the enforcement verified over whatever the process configuration
|
||||||
|
* happened to be. A consensus decision that depends on global state cannot be tested honestly,
|
||||||
|
* and cannot be read either. The factories below fetch the value once, at construction, where it
|
||||||
|
* is visible.
|
||||||
|
*
|
||||||
|
* @param armedFromBlock first height at which enforcement applies
|
||||||
|
* @param registry the signer registry
|
||||||
|
*/
|
||||||
|
public PqProposalEnforcement(final long armedFromBlock, final PqSignerRegistry registry) {
|
||||||
|
this(armedFromBlock, registry, PqAnchorProducer.config().chainId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The configured enforcement, read FRESH on every call.
|
||||||
|
*
|
||||||
|
* @return the armed enforcement, or null when the property is set nowhere
|
||||||
|
* @throws IllegalStateException AERE-PQC-PROPOSAL-ENF-01 when the value is present but is not a
|
||||||
|
* non-negative decimal height
|
||||||
|
*/
|
||||||
|
public static PqProposalEnforcement fromSystemConfig() {
|
||||||
|
String raw = System.getProperty(PROPERTY_FORK_BLOCK);
|
||||||
|
if (raw == null) {
|
||||||
|
raw = System.getenv(ENV_FORK_BLOCK);
|
||||||
|
}
|
||||||
|
if (raw == null || raw.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final long armedFrom;
|
||||||
|
try {
|
||||||
|
armedFrom = Long.parseLong(raw.trim());
|
||||||
|
if (armedFrom < 0) {
|
||||||
|
throw new NumberFormatException("negative");
|
||||||
|
}
|
||||||
|
} catch (final NumberFormatException e) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"AERE-PQC-PROPOSAL-ENF-01: "
|
||||||
|
+ PROPERTY_FORK_BLOCK
|
||||||
|
+ " is set but not a non-negative block height: '"
|
||||||
|
+ raw
|
||||||
|
+ "'. A mistyped value must refuse, never silently disarm.");
|
||||||
|
}
|
||||||
|
return new PqProposalEnforcement(armedFrom, PqSignerRegistry.falconSealSupport());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether enforcement is active at this height. */
|
||||||
|
public boolean armedAt(final long height) {
|
||||||
|
return height >= armedFromBlock;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decides whether a PROPOSAL may be accepted.
|
||||||
|
*
|
||||||
|
* @param height the height the PROPOSAL targets (the round's sequence number)
|
||||||
|
* @param round the PROPOSAL's round; it is part of the signed message
|
||||||
|
* @param author the RECOVERED author of the signed message (from its ECDSA signature)
|
||||||
|
* @param digest the digest of the proposed block
|
||||||
|
* @param seal the post-quantum seal carried by the payload, if any
|
||||||
|
* @return empty when the PROPOSAL is acceptable; otherwise the refusal, with names and numbers
|
||||||
|
*/
|
||||||
|
public Optional<String> refusal(
|
||||||
|
final long height,
|
||||||
|
final int round,
|
||||||
|
final Address author,
|
||||||
|
final Hash digest,
|
||||||
|
final Optional<FalconSeal> seal) {
|
||||||
|
if (!armedAt(height)) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
if (seal == null || seal.isEmpty()) {
|
||||||
|
return Optional.of(
|
||||||
|
"AERE FULL-PQ: proposal at height " + height + " round " + round
|
||||||
|
+ " carries NO post-quantum seal and enforcement is armed from " + armedFromBlock
|
||||||
|
+ " - the proposal is refused");
|
||||||
|
}
|
||||||
|
final FalconSeal fs = seal.get();
|
||||||
|
final Address bound;
|
||||||
|
try {
|
||||||
|
bound = registry.addressForIndexAtOwnHead(height, fs.getValidatorIndex());
|
||||||
|
} catch (final RuntimeException e) {
|
||||||
|
return Optional.of(
|
||||||
|
"AERE FULL-PQ: registry refused index " + fs.getValidatorIndex() + " at height "
|
||||||
|
+ height + ": " + e.getMessage());
|
||||||
|
}
|
||||||
|
if (bound == null || !bound.equals(author)) {
|
||||||
|
return Optional.of(
|
||||||
|
"AERE FULL-PQ: proposal seal index " + fs.getValidatorIndex() + " is bound to " + bound
|
||||||
|
+ " but the proposal was authored by " + author
|
||||||
|
+ " - a seal cannot vouch for someone else's proposal");
|
||||||
|
}
|
||||||
|
|
||||||
|
final Bytes32 message;
|
||||||
|
try {
|
||||||
|
message = PqAnchor.proposalMessage(chainId, height, round, digest.getBytes());
|
||||||
|
} catch (final RuntimeException e) {
|
||||||
|
// A message we cannot build means we cannot judge, and "cannot judge" must never be a pass:
|
||||||
|
// that would be exactly the silent disarming this file exists to refuse.
|
||||||
|
return Optional.of(
|
||||||
|
"AERE FULL-PQ: could not build the proposal message at height " + height + " round "
|
||||||
|
+ round + ": " + e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
final boolean valid;
|
||||||
|
try {
|
||||||
|
valid = registry.verifyAtOwnHead(height, fs.getValidatorIndex(), message, fs.getSignature());
|
||||||
|
} catch (final RuntimeException e) {
|
||||||
|
return Optional.of(
|
||||||
|
"AERE FULL-PQ: verification threw for index " + fs.getValidatorIndex() + " at height "
|
||||||
|
+ height + ": " + e.getMessage());
|
||||||
|
}
|
||||||
|
if (!valid) {
|
||||||
|
return Optional.of(
|
||||||
|
"AERE FULL-PQ: post-quantum seal of index " + fs.getValidatorIndex()
|
||||||
|
+ " does NOT verify over the proposal message at height " + height + " round " + round
|
||||||
|
+ " - the proposal is refused");
|
||||||
|
}
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,182 @@
|
|||||||
|
/*
|
||||||
|
* Copyright contributors to Hyperledger Besu.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
|
||||||
|
* the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
|
||||||
|
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations under the License.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
package org.hyperledger.besu.consensus.qbft.core.validation;
|
||||||
|
|
||||||
|
import static com.google.common.base.Preconditions.checkState;
|
||||||
|
|
||||||
|
import org.hyperledger.besu.consensus.common.bft.ConsensusRoundIdentifier;
|
||||||
|
import org.hyperledger.besu.consensus.common.bft.payload.SignedData;
|
||||||
|
import org.hyperledger.besu.consensus.qbft.core.payload.ProposalPayload;
|
||||||
|
import org.hyperledger.besu.consensus.qbft.core.types.QbftBlock;
|
||||||
|
import org.hyperledger.besu.consensus.qbft.core.types.QbftBlockValidator;
|
||||||
|
import org.hyperledger.besu.datatypes.Address;
|
||||||
|
import org.hyperledger.besu.ethereum.mainnet.block.access.list.BlockAccessList;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import com.google.common.annotations.VisibleForTesting;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Proposal payload validator.
|
||||||
|
*
|
||||||
|
* <p>AERE (2026-08-30): an OPTIONAL post-quantum enforcement hook, gated on height (see {@link
|
||||||
|
* PqProposalEnforcement}). When no enforcement is supplied, behaviour is byte for byte the upstream
|
||||||
|
* one - and that is the configuration of every node today. Same pattern as the PREPARE and commit
|
||||||
|
* validators, deliberately: a second rendering of the same idea, written differently, diverges
|
||||||
|
* eventually.
|
||||||
|
*
|
||||||
|
* <p>The 3-argument constructor - the one production code calls - wires the enforcement itself
|
||||||
|
* from the system configuration, exactly as {@link PrepareValidator} does. That self-wiring is the
|
||||||
|
* security property: an enforcement that had to be passed in explicitly could be dropped by any
|
||||||
|
* refactor without a single test failing.
|
||||||
|
*/
|
||||||
|
public class ProposalPayloadValidator {
|
||||||
|
|
||||||
|
private static final String ERROR_PREFIX = "Invalid Proposal Payload";
|
||||||
|
|
||||||
|
private static final Logger LOG = LoggerFactory.getLogger(ProposalPayloadValidator.class);
|
||||||
|
private final Address expectedProposer;
|
||||||
|
private final ConsensusRoundIdentifier targetRound;
|
||||||
|
private final QbftBlockValidator blockValidator;
|
||||||
|
private final PqProposalEnforcement pqEnforcement;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instantiates a new Proposal payload validator, with the post-quantum enforcement wired from
|
||||||
|
* the system configuration. This is the constructor production code calls.
|
||||||
|
*
|
||||||
|
* @param expectedProposer the expected proposer
|
||||||
|
* @param targetRound the target round
|
||||||
|
* @param blockValidator the block validator
|
||||||
|
*/
|
||||||
|
@VisibleForTesting
|
||||||
|
public ProposalPayloadValidator(
|
||||||
|
final Address expectedProposer,
|
||||||
|
final ConsensusRoundIdentifier targetRound,
|
||||||
|
final QbftBlockValidator blockValidator) {
|
||||||
|
this(expectedProposer, targetRound, blockValidator, PqProposalEnforcement.fromSystemConfig());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instantiates a new Proposal payload validator with an explicit post-quantum enforcement. Test
|
||||||
|
* seam; production goes through the 3-argument constructor above.
|
||||||
|
*
|
||||||
|
* @param expectedProposer the expected proposer
|
||||||
|
* @param targetRound the target round
|
||||||
|
* @param blockValidator the block validator
|
||||||
|
* @param pqEnforcement the post-quantum enforcement, or null for upstream behaviour
|
||||||
|
*/
|
||||||
|
@VisibleForTesting
|
||||||
|
public ProposalPayloadValidator(
|
||||||
|
final Address expectedProposer,
|
||||||
|
final ConsensusRoundIdentifier targetRound,
|
||||||
|
final QbftBlockValidator blockValidator,
|
||||||
|
final PqProposalEnforcement pqEnforcement) {
|
||||||
|
this.expectedProposer = expectedProposer;
|
||||||
|
this.targetRound = targetRound;
|
||||||
|
this.blockValidator = blockValidator;
|
||||||
|
this.pqEnforcement = pqEnforcement;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate.
|
||||||
|
*
|
||||||
|
* @param signedPayload the signed Proposal payload
|
||||||
|
* @return the boolean
|
||||||
|
*/
|
||||||
|
public boolean validate(final SignedData<ProposalPayload> signedPayload) {
|
||||||
|
return validate(signedPayload, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate without block validation.
|
||||||
|
*
|
||||||
|
* @param signedPayload the signed Proposal payload
|
||||||
|
* @return the boolean
|
||||||
|
*/
|
||||||
|
public boolean validateWithoutBlockValidation(final SignedData<ProposalPayload> signedPayload) {
|
||||||
|
return validate(signedPayload, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate with optional block validation.
|
||||||
|
*
|
||||||
|
* @param signedPayload the signed Proposal payload
|
||||||
|
* @param validateBlock whether to validate the block
|
||||||
|
* @return the boolean
|
||||||
|
*/
|
||||||
|
private boolean validate(
|
||||||
|
final SignedData<ProposalPayload> signedPayload, final boolean validateBlock) {
|
||||||
|
|
||||||
|
if (!signedPayload.getAuthor().equals(expectedProposer)) {
|
||||||
|
LOG.info("{}: proposal created by non-proposer", ERROR_PREFIX);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final ProposalPayload payload = signedPayload.getPayload();
|
||||||
|
|
||||||
|
if (!payload.getRoundIdentifier().equals(targetRound)) {
|
||||||
|
LOG.info("{}: proposal is not for expected round", ERROR_PREFIX);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final QbftBlock block = payload.getProposedBlock();
|
||||||
|
if (validateBlock && !validateBlock(block, payload.getBlockAccessList())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (block.getHeader().getNumber() != payload.getRoundIdentifier().getSequenceNumber()) {
|
||||||
|
LOG.info("{}: block number does not match sequence number", ERROR_PREFIX);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// AERE full-PQ: from the armed height, a proposal is not accepted without a valid post-quantum
|
||||||
|
// seal from its OWN proposer. Below the height, and on every node without the property, this
|
||||||
|
// is a null check and nothing more - the upstream path, call for call.
|
||||||
|
if (pqEnforcement != null) {
|
||||||
|
final Optional<String> refusal =
|
||||||
|
pqEnforcement.refusal(
|
||||||
|
payload.getRoundIdentifier().getSequenceNumber(),
|
||||||
|
payload.getRoundIdentifier().getRoundNumber(),
|
||||||
|
signedPayload.getAuthor(),
|
||||||
|
block.getHash(),
|
||||||
|
payload.getFalconSeal());
|
||||||
|
if (refusal.isPresent()) {
|
||||||
|
LOG.info("{}: {}", ERROR_PREFIX, refusal.get());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean validateBlock(
|
||||||
|
final QbftBlock block, final Optional<BlockAccessList> blockAccessList) {
|
||||||
|
checkState(blockValidator != null, "block validation not possible, no block validator.");
|
||||||
|
|
||||||
|
final var validationResult = blockValidator.validateBlock(block, blockAccessList);
|
||||||
|
|
||||||
|
if (!validationResult.success()) {
|
||||||
|
LOG.info(
|
||||||
|
"{}: block did not pass validation. Reason {}",
|
||||||
|
ERROR_PREFIX,
|
||||||
|
validationResult.errorMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,202 @@
|
|||||||
|
/*
|
||||||
|
* Copyright contributors to Besu.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
|
||||||
|
* the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
|
||||||
|
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations under the License.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
package org.hyperledger.besu.consensus.qbft.core.payload;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.lenient;
|
||||||
|
|
||||||
|
import org.hyperledger.besu.consensus.common.bft.ConsensusRoundIdentifier;
|
||||||
|
import org.hyperledger.besu.consensus.common.bft.FalconSeal;
|
||||||
|
import org.hyperledger.besu.consensus.common.bft.PqAnchor;
|
||||||
|
import org.hyperledger.besu.consensus.qbft.core.types.QbftBlock;
|
||||||
|
import org.hyperledger.besu.consensus.qbft.core.types.QbftBlockCodec;
|
||||||
|
import org.hyperledger.besu.ethereum.rlp.BytesValueRLPInput;
|
||||||
|
import org.hyperledger.besu.ethereum.rlp.BytesValueRLPOutput;
|
||||||
|
import org.hyperledger.besu.ethereum.rlp.RLPException;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import org.apache.tuweni.bytes.Bytes;
|
||||||
|
import org.apache.tuweni.bytes.Bytes32;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The wire can carry a post-quantum seal on a PROPOSAL, and NOTHING emits one yet (AERE PQ,
|
||||||
|
* 2026-08-30, the hot-path step after PREPARE).
|
||||||
|
*
|
||||||
|
* <p>The test that matters most is the first one: a proposal without a seal encodes EXACTLY as
|
||||||
|
* upstream does. The block codec is mocked and contributes zero bytes on both sides of the round
|
||||||
|
* trip, which is what lets the surrounding list structure be compared against a hand-built
|
||||||
|
* expected form instead of against a run of the code under test.
|
||||||
|
*
|
||||||
|
* <p>The strictness tests close the malleability door this file's javadoc names: an authenticated
|
||||||
|
* payload whose decoder silently ignored trailing elements would let two different byte strings
|
||||||
|
* authenticate to the same proposer.
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
public class ProposalPayloadPqTest {
|
||||||
|
|
||||||
|
private static final ConsensusRoundIdentifier ROUND_ID = new ConsensusRoundIdentifier(7, 3);
|
||||||
|
|
||||||
|
@Mock private QbftBlockCodec blockEncoder;
|
||||||
|
@Mock private QbftBlock block;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void wireCodec() {
|
||||||
|
// The mock codec writes nothing and reads nothing: the block contributes zero elements on both
|
||||||
|
// sides, so the tests compare the STRUCTURE around it, which is what this file changed.
|
||||||
|
lenient().when(blockEncoder.readFrom(any())).thenReturn(block);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The old form with the mocked block: RLP[ sequence, round, null-BAL ]. Built by hand. */
|
||||||
|
private static Bytes oldForm() {
|
||||||
|
final BytesValueRLPOutput out = new BytesValueRLPOutput();
|
||||||
|
out.startList();
|
||||||
|
out.writeLongScalar(ROUND_ID.getSequenceNumber());
|
||||||
|
out.writeIntScalar(ROUND_ID.getRoundNumber());
|
||||||
|
out.writeNull();
|
||||||
|
out.endList();
|
||||||
|
return out.encoded();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void aProposalWithoutASealEncodesEXACTLYAsBefore() {
|
||||||
|
final ProposalPayload p = new ProposalPayload(ROUND_ID, block, blockEncoder, Optional.empty());
|
||||||
|
assertThat(p.encoded()).isEqualTo(oldForm());
|
||||||
|
// and the signature hash, which is precisely what binds the proposer to the message
|
||||||
|
assertThat(p.hashForSignature())
|
||||||
|
.isEqualTo(
|
||||||
|
new ProposalPayload(ROUND_ID, block, blockEncoder, Optional.empty(), Optional.empty())
|
||||||
|
.hashForSignature());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void aProposalWithoutASealReadsBackIdentical() {
|
||||||
|
final Bytes encoded = new ProposalPayload(ROUND_ID, block, blockEncoder, Optional.empty()).encoded();
|
||||||
|
final ProposalPayload read =
|
||||||
|
ProposalPayload.readFrom(new BytesValueRLPInput(encoded, false), blockEncoder);
|
||||||
|
assertThat(read.getFalconSeal()).isEmpty();
|
||||||
|
assertThat(read.getRoundIdentifier()).isEqualTo(ROUND_ID);
|
||||||
|
assertThat(read.encoded()).isEqualTo(encoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void aProposalWithASealReadsBackIdentical() {
|
||||||
|
final FalconSeal seal = new FalconSeal(4, Bytes.fromHexString("0xdeadbeef"));
|
||||||
|
final ProposalPayload p =
|
||||||
|
new ProposalPayload(ROUND_ID, block, blockEncoder, Optional.empty(), Optional.of(seal));
|
||||||
|
final Bytes encoded = p.encoded();
|
||||||
|
|
||||||
|
// it is longer than the old form, and CONTAINS it as a prefix of the content
|
||||||
|
assertThat(encoded.size()).isGreaterThan(oldForm().size());
|
||||||
|
|
||||||
|
final ProposalPayload read =
|
||||||
|
ProposalPayload.readFrom(new BytesValueRLPInput(encoded, false), blockEncoder);
|
||||||
|
assertThat(read.getFalconSeal()).isPresent();
|
||||||
|
assertThat(read.getFalconSeal().get().getValidatorIndex()).isEqualTo(4);
|
||||||
|
assertThat(read.getFalconSeal().get().getSignature())
|
||||||
|
.isEqualTo(Bytes.fromHexString("0xdeadbeef"));
|
||||||
|
assertThat(read.encoded()).isEqualTo(encoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void aSealChangesTheSignatureHash() {
|
||||||
|
// If it did not change it, the proposer's ECDSA signature would not cover the seal, and anyone
|
||||||
|
// could paste a foreign index onto an otherwise valid proposal.
|
||||||
|
final ProposalPayload without = new ProposalPayload(ROUND_ID, block, blockEncoder, Optional.empty());
|
||||||
|
final ProposalPayload with =
|
||||||
|
new ProposalPayload(
|
||||||
|
ROUND_ID,
|
||||||
|
block,
|
||||||
|
blockEncoder,
|
||||||
|
Optional.empty(),
|
||||||
|
Optional.of(new FalconSeal(4, Bytes.fromHexString("0xdeadbeef"))));
|
||||||
|
assertThat(with.hashForSignature()).isNotEqualTo(without.hashForSignature());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void anElementAfterTheSealIsRefused() {
|
||||||
|
// A decoder that ignored it would re-encode without it: two different byte strings would
|
||||||
|
// authenticate to the same proposer. It is refused.
|
||||||
|
final BytesValueRLPOutput out = new BytesValueRLPOutput();
|
||||||
|
out.startList();
|
||||||
|
out.writeLongScalar(ROUND_ID.getSequenceNumber());
|
||||||
|
out.writeIntScalar(ROUND_ID.getRoundNumber());
|
||||||
|
out.writeNull();
|
||||||
|
out.startList();
|
||||||
|
out.writeIntScalar(4);
|
||||||
|
out.writeBytes(Bytes.fromHexString("0xdeadbeef"));
|
||||||
|
out.endList();
|
||||||
|
out.writeIntScalar(1); // the trailing element nothing accounts for
|
||||||
|
out.endList();
|
||||||
|
|
||||||
|
assertThatThrownBy(
|
||||||
|
() -> ProposalPayload.readFrom(new BytesValueRLPInput(out.encoded(), false), blockEncoder))
|
||||||
|
.isInstanceOf(RLPException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void aFifthElementThatIsNotASealIsRefused() {
|
||||||
|
final BytesValueRLPOutput out = new BytesValueRLPOutput();
|
||||||
|
out.startList();
|
||||||
|
out.writeLongScalar(ROUND_ID.getSequenceNumber());
|
||||||
|
out.writeIntScalar(ROUND_ID.getRoundNumber());
|
||||||
|
out.writeNull();
|
||||||
|
out.writeBytes(Bytes.fromHexString("0x01")); // not a [index, signature] list
|
||||||
|
out.endList();
|
||||||
|
|
||||||
|
assertThatThrownBy(
|
||||||
|
() -> ProposalPayload.readFrom(new BytesValueRLPInput(out.encoded(), false), blockEncoder))
|
||||||
|
.isInstanceOf(RLPException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- domain separation: the security part of the design --------------------------------------
|
||||||
|
|
||||||
|
private static final Bytes32 DIGEST =
|
||||||
|
Bytes32.fromHexString("0x000000000000000000000000000000000000000000000000000000000000002a");
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void theProposalMessageIsNeitherThePrepareNorTheCommitMessage() {
|
||||||
|
// If any two were the same, a seal given HONESTLY in one role could be replayed in the other:
|
||||||
|
// an offer counted as a vote, or a vote replayed as an offer.
|
||||||
|
final Bytes32 proposal = PqAnchor.proposalMessage(2800L, 100L, 3, DIGEST);
|
||||||
|
assertThat(proposal).isNotEqualTo(PqAnchor.prepareMessage(2800L, 100L, 3, DIGEST));
|
||||||
|
assertThat(proposal).isNotEqualTo(PqAnchor.commitMessage(2800L, 100L, DIGEST));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void theProposalMessageDependsOnROUNDChainAndHeight() {
|
||||||
|
final Bytes32 base = PqAnchor.proposalMessage(2800L, 100L, 3, DIGEST);
|
||||||
|
assertThat(PqAnchor.proposalMessage(2800L, 100L, 4, DIGEST)).isNotEqualTo(base);
|
||||||
|
assertThat(PqAnchor.proposalMessage(2801L, 100L, 3, DIGEST)).isNotEqualTo(base);
|
||||||
|
assertThat(PqAnchor.proposalMessage(2800L, 101L, 3, DIGEST)).isNotEqualTo(base);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void theProposalMessageRefusesImpossibleInputs() {
|
||||||
|
assertThatThrownBy(() -> PqAnchor.proposalMessage(2800L, -1L, 3, DIGEST))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class);
|
||||||
|
assertThatThrownBy(() -> PqAnchor.proposalMessage(2800L, 100L, -1, DIGEST))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class);
|
||||||
|
assertThatThrownBy(() -> PqAnchor.proposalMessage(2800L, 100L, 3, Bytes.fromHexString("0x00")))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,297 @@
|
|||||||
|
/*
|
||||||
|
* Copyright contributors to Besu.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
|
||||||
|
* the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
|
||||||
|
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations under the License.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
package org.hyperledger.besu.consensus.qbft.core.validation;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
import org.hyperledger.besu.consensus.common.bft.FalconSeal;
|
||||||
|
import org.hyperledger.besu.consensus.common.bft.PqAnchor;
|
||||||
|
import org.hyperledger.besu.consensus.common.bft.PqSignerRegistry;
|
||||||
|
import org.hyperledger.besu.consensus.common.bft.SealScheme;
|
||||||
|
import org.hyperledger.besu.consensus.common.bft.SealSchemes;
|
||||||
|
import org.hyperledger.besu.crypto.SecureRandomProvider;
|
||||||
|
import org.hyperledger.besu.datatypes.Address;
|
||||||
|
import org.hyperledger.besu.datatypes.Hash;
|
||||||
|
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import org.apache.tuweni.bytes.Bytes;
|
||||||
|
import org.apache.tuweni.bytes.Bytes32;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PROPOSAL ENFORCEMENT. The structure follows PqPrepareEnforcementTest deliberately - the same
|
||||||
|
* eight ways of being wrong, each with the test that closes it.
|
||||||
|
*
|
||||||
|
* <ol>
|
||||||
|
* <li>below the arming height NOTHING changes - the condition for the binary to sit on the fleet;
|
||||||
|
* <li>above it, a PROPOSAL without a seal is refused;
|
||||||
|
* <li>a seal from ANOTHER validator does not vouch for this proposer;
|
||||||
|
* <li>a seal over a DIFFERENT MESSAGE does not pass - tested against BOTH the PREPARE and the
|
||||||
|
* COMMIT messages, because a proposal seal that passed under either domain would let an
|
||||||
|
* honest proposal seal be replayed as a vote, or an honest vote seal be replayed as a
|
||||||
|
* proposal;
|
||||||
|
* <li>a seal from a DIFFERENT ROUND does not pass;
|
||||||
|
* <li>an index the registry binds to nobody does not pass;
|
||||||
|
* <li>a mistyped configuration REFUSES, it does not disarm;
|
||||||
|
* <li>the enforcement gate and the emission gate are two different switches.
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* <p>The keys are REAL Falcon keys, generated in-process, and verification goes through the real
|
||||||
|
* scheme. A test with fake signatures would prove that we can compare strings, not that the
|
||||||
|
* enforcement enforces.
|
||||||
|
*/
|
||||||
|
class PqProposalEnforcementTest {
|
||||||
|
|
||||||
|
private static final long ARMED_FROM = 1_000_000L;
|
||||||
|
private static final int ROUND = 3;
|
||||||
|
private static final Address PROPOSER_0 = Address.fromHexString("0x" + "aa".repeat(20));
|
||||||
|
private static final Address VALIDATOR_1 = Address.fromHexString("0x" + "bb".repeat(20));
|
||||||
|
private static final Hash DIGEST = Hash.hash(Bytes.of(8, 8, 8));
|
||||||
|
private static final long CHAIN_ID = 2800L;
|
||||||
|
|
||||||
|
private final SecureRandom random = SecureRandomProvider.createSecureRandom();
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void clearProperty() {
|
||||||
|
System.clearProperty(PqProposalEnforcement.PROPERTY_FORK_BLOCK);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test registry: programmed index-to-address bindings + verification through the REAL scheme. */
|
||||||
|
private static final class TestRegistry implements PqSignerRegistry {
|
||||||
|
final Map<Integer, Address> bindings;
|
||||||
|
final Map<Integer, byte[]> keys;
|
||||||
|
|
||||||
|
TestRegistry(final Map<Integer, Address> bindings, final Map<Integer, byte[]> keys) {
|
||||||
|
this.bindings = bindings;
|
||||||
|
this.keys = keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Address addressForIndexAtHistoric(final long blockNumber, final int validatorIndex) {
|
||||||
|
return bindings.get(validatorIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Address addressForIndexAtOwnHead(final long blockNumber, final int validatorIndex) {
|
||||||
|
return bindings.get(validatorIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean verifyAtHistoric(
|
||||||
|
final long blockNumber, final int validatorIndex, final Bytes message, final Bytes signature) {
|
||||||
|
return verifyAtOwnHead(blockNumber, validatorIndex, message, signature);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean verifyAtOwnHead(
|
||||||
|
final long blockNumber, final int validatorIndex, final Bytes message, final Bytes signature) {
|
||||||
|
final byte[] pk = keys.get(validatorIndex);
|
||||||
|
if (pk == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return SealSchemes.FALCON_512.verifyRaw(pk, message.toArray(), signature.toArray());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private record World(
|
||||||
|
PqProposalEnforcement enforcement,
|
||||||
|
FalconSeal valid0,
|
||||||
|
SealScheme.GeneratedPair k0,
|
||||||
|
SealScheme.GeneratedPair k1) {}
|
||||||
|
|
||||||
|
private Bytes32 proposalMessage(final long h, final int round) {
|
||||||
|
return PqAnchor.proposalMessage(CHAIN_ID, h, round, DIGEST.getBytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Two validators with real Falcon keys; validator 0's seal over the PROPOSAL message. */
|
||||||
|
private World world() {
|
||||||
|
final SealScheme.GeneratedPair k0 = SealSchemes.FALCON_512.generate(random);
|
||||||
|
final SealScheme.GeneratedPair k1 = SealSchemes.FALCON_512.generate(random);
|
||||||
|
final byte[] sig0 =
|
||||||
|
SealSchemes.FALCON_512
|
||||||
|
.sign(k0.privateKey(), proposalMessage(ARMED_FROM, ROUND).toArray())
|
||||||
|
.orElseThrow();
|
||||||
|
final TestRegistry reg =
|
||||||
|
new TestRegistry(
|
||||||
|
Map.of(0, PROPOSER_0, 1, VALIDATOR_1),
|
||||||
|
Map.of(0, k0.publicRegistryForm(), 1, k1.publicRegistryForm()));
|
||||||
|
return new World(
|
||||||
|
new PqProposalEnforcement(ARMED_FROM, reg, CHAIN_ID),
|
||||||
|
new FalconSeal(0, Bytes.wrap(sig0)),
|
||||||
|
k0,
|
||||||
|
k1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
// 1. BELOW the arming height NOTHING changes. The condition for the binary to sit on the fleet.
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
@Test
|
||||||
|
void belowTheArmingHeightAnUnsealedProposalIsAccepted() {
|
||||||
|
final World w = world();
|
||||||
|
assertThat(w.enforcement().armedAt(ARMED_FROM - 1)).isFalse();
|
||||||
|
assertThat(w.enforcement().refusal(ARMED_FROM - 1, ROUND, PROPOSER_0, DIGEST, Optional.empty()))
|
||||||
|
.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
// 2. ABOVE it, a PROPOSAL without a seal is refused.
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
@Test
|
||||||
|
void aboveTheHeightAnUnsealedProposalIsRefused() {
|
||||||
|
final World w = world();
|
||||||
|
final Optional<String> refusal =
|
||||||
|
w.enforcement().refusal(ARMED_FROM, ROUND, PROPOSER_0, DIGEST, Optional.empty());
|
||||||
|
assertThat(refusal).isPresent();
|
||||||
|
assertThat(refusal.get()).contains("carries NO post-quantum seal");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
// 3. A GOOD seal from the proposer passes.
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
@Test
|
||||||
|
void aGoodSealFromTheProposerPasses() {
|
||||||
|
final World w = world();
|
||||||
|
assertThat(w.enforcement().refusal(ARMED_FROM, ROUND, PROPOSER_0, DIGEST, Optional.of(w.valid0())))
|
||||||
|
.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
// 4. The same seal, a different author: it does not vouch for somebody else.
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
@Test
|
||||||
|
void aSealDoesNotVouchForAnotherProposer() {
|
||||||
|
final World w = world();
|
||||||
|
final Optional<String> refusal =
|
||||||
|
w.enforcement().refusal(ARMED_FROM, ROUND, VALIDATOR_1, DIGEST, Optional.of(w.valid0()));
|
||||||
|
assertThat(refusal).isPresent();
|
||||||
|
assertThat(refusal.get()).contains("cannot vouch for someone else's proposal");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
// 5. DOMAIN SEPARATION, in both directions that matter here: a seal given HONESTLY over the
|
||||||
|
// PREPARE message, and one over the COMMIT message, must not pass as a PROPOSAL seal. Either
|
||||||
|
// passing would make honest seals transferable between an offer and a vote.
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
@Test
|
||||||
|
void aSealOverThePREPAREMessageDoesNotPassAsPROPOSAL() {
|
||||||
|
final World w = world();
|
||||||
|
final Bytes32 prepareMsg = PqAnchor.prepareMessage(CHAIN_ID, ARMED_FROM, ROUND, DIGEST.getBytes());
|
||||||
|
final byte[] sig =
|
||||||
|
SealSchemes.FALCON_512.sign(w.k0().privateKey(), prepareMsg.toArray()).orElseThrow();
|
||||||
|
|
||||||
|
final Optional<String> refusal =
|
||||||
|
w.enforcement()
|
||||||
|
.refusal(
|
||||||
|
ARMED_FROM, ROUND, PROPOSER_0, DIGEST, Optional.of(new FalconSeal(0, Bytes.wrap(sig))));
|
||||||
|
assertThat(refusal).isPresent();
|
||||||
|
assertThat(refusal.get()).contains("does NOT verify over the proposal message");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aSealOverTheCOMMITMessageDoesNotPassAsPROPOSAL() {
|
||||||
|
final World w = world();
|
||||||
|
final Bytes32 commitMsg = PqAnchor.commitMessage(CHAIN_ID, ARMED_FROM, DIGEST.getBytes());
|
||||||
|
final byte[] sig =
|
||||||
|
SealSchemes.FALCON_512.sign(w.k0().privateKey(), commitMsg.toArray()).orElseThrow();
|
||||||
|
|
||||||
|
final Optional<String> refusal =
|
||||||
|
w.enforcement()
|
||||||
|
.refusal(
|
||||||
|
ARMED_FROM, ROUND, PROPOSER_0, DIGEST, Optional.of(new FalconSeal(0, Bytes.wrap(sig))));
|
||||||
|
assertThat(refusal).isPresent();
|
||||||
|
assertThat(refusal.get()).contains("does NOT verify over the proposal message");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
// 6. The ROUND is in the preimage: a seal from a failed round does not open another one.
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
@Test
|
||||||
|
void aSealFromAnotherROUNDDoesNotPass() {
|
||||||
|
final World w = world();
|
||||||
|
final byte[] sigOtherRound =
|
||||||
|
SealSchemes.FALCON_512
|
||||||
|
.sign(w.k0().privateKey(), proposalMessage(ARMED_FROM, ROUND + 1).toArray())
|
||||||
|
.orElseThrow();
|
||||||
|
final Optional<String> refusal =
|
||||||
|
w.enforcement()
|
||||||
|
.refusal(
|
||||||
|
ARMED_FROM,
|
||||||
|
ROUND,
|
||||||
|
PROPOSER_0,
|
||||||
|
DIGEST,
|
||||||
|
Optional.of(new FalconSeal(0, Bytes.wrap(sigOtherRound))));
|
||||||
|
assertThat(refusal).isPresent();
|
||||||
|
assertThat(refusal.get()).contains("does NOT verify over the proposal message");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
// 7. An index the registry binds to nobody.
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
@Test
|
||||||
|
void anUNBOUNDIndexDoesNotPass() {
|
||||||
|
final World w = world();
|
||||||
|
final Optional<String> refusal =
|
||||||
|
w.enforcement()
|
||||||
|
.refusal(
|
||||||
|
ARMED_FROM,
|
||||||
|
ROUND,
|
||||||
|
PROPOSER_0,
|
||||||
|
DIGEST,
|
||||||
|
Optional.of(new FalconSeal(99, w.valid0().getSignature())));
|
||||||
|
assertThat(refusal).isPresent();
|
||||||
|
assertThat(refusal.get()).contains("is bound to null");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
// 8. CONFIGURATION: absent = disarmed; a mistyped value = REFUSAL, never a silent disarming.
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
@Test
|
||||||
|
void withoutThePropertyTheEnforcementIsNULL() {
|
||||||
|
assertThat(PqProposalEnforcement.fromSystemConfig()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aGoodValueArms() {
|
||||||
|
System.setProperty(PqProposalEnforcement.PROPERTY_FORK_BLOCK, "1234567");
|
||||||
|
final PqProposalEnforcement e = PqProposalEnforcement.fromSystemConfig();
|
||||||
|
assertThat(e).isNotNull();
|
||||||
|
assertThat(e.armedAt(1_234_566L)).isFalse();
|
||||||
|
assertThat(e.armedAt(1_234_567L)).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aMISTYPEDValueRefusesLoudly() {
|
||||||
|
for (final String bad : new String[] {"not-a-number", "1_234_567", "-1", "1e6"}) {
|
||||||
|
System.setProperty(PqProposalEnforcement.PROPERTY_FORK_BLOCK, bad);
|
||||||
|
assertThatThrownBy(PqProposalEnforcement::fromSystemConfig)
|
||||||
|
.as("the value '%s'", bad)
|
||||||
|
.isInstanceOf(IllegalStateException.class)
|
||||||
|
.hasMessageContaining("AERE-PQC-PROPOSAL-ENF-01");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void theENFORCEMENTGateIsNotTheEMISSIONGate() {
|
||||||
|
// Two distinct properties: a fleet can EMIT for months before anything ENFORCES. If they were
|
||||||
|
// one switch, the first node that started emitting would also start refusing - a flag day.
|
||||||
|
assertThat(PqProposalEnforcement.PROPERTY_FORK_BLOCK)
|
||||||
|
.isNotEqualTo("aere.pq.proposalPq.attachBlock");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,133 @@
|
|||||||
|
/*
|
||||||
|
* Copyright contributors to Besu.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
|
||||||
|
* the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
|
||||||
|
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations under the License.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
package org.hyperledger.besu.consensus.qbft.core.validation;
|
||||||
|
|
||||||
|
import static java.util.Collections.emptyList;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
import org.hyperledger.besu.consensus.common.bft.ConsensusRoundIdentifier;
|
||||||
|
import org.hyperledger.besu.consensus.common.bft.PqSignerRegistry;
|
||||||
|
import org.hyperledger.besu.consensus.qbft.core.QbftBlockTestFixture;
|
||||||
|
import org.hyperledger.besu.consensus.qbft.core.messagewrappers.Proposal;
|
||||||
|
import org.hyperledger.besu.consensus.qbft.core.payload.MessageFactory;
|
||||||
|
import org.hyperledger.besu.consensus.qbft.core.types.QbftBlock;
|
||||||
|
import org.hyperledger.besu.consensus.qbft.core.types.QbftBlockCodec;
|
||||||
|
import org.hyperledger.besu.consensus.qbft.core.types.QbftBlockHeader;
|
||||||
|
import org.hyperledger.besu.cryptoservices.NodeKey;
|
||||||
|
import org.hyperledger.besu.cryptoservices.NodeKeyUtils;
|
||||||
|
import org.hyperledger.besu.datatypes.Address;
|
||||||
|
import org.hyperledger.besu.ethereum.core.Util;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import org.apache.tuweni.bytes.Bytes;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE WIRING, not the class: does {@link ProposalPayloadValidator} actually CALL the enforcement?
|
||||||
|
*
|
||||||
|
* <p>Same reason the PREPARE twin exists: a class present in the binary, environment variables
|
||||||
|
* visible to the process, and code that never runs is exactly the failure paid for on 2026-08-28.
|
||||||
|
* The registry here REFUSES everything, so this does not measure cryptography (that has its own
|
||||||
|
* test), only whether the decision passes through the hook. The pair is all it takes: the same
|
||||||
|
* signed proposal, once with enforcement and once without.
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
public class ProposalPayloadValidatorPqWiringTest {
|
||||||
|
|
||||||
|
private static final long HEIGHT = 1L;
|
||||||
|
|
||||||
|
private final ConsensusRoundIdentifier round = new ConsensusRoundIdentifier((int) HEIGHT, 0);
|
||||||
|
@Mock private QbftBlockCodec blockEncoder;
|
||||||
|
|
||||||
|
private final NodeKey nodeKey = NodeKeyUtils.generate();
|
||||||
|
private final Address proposer = Util.publicKeyToAddress(nodeKey.getPublicKey());
|
||||||
|
private MessageFactory messageFactory;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
public void setup() {
|
||||||
|
messageFactory = new MessageFactory(nodeKey, blockEncoder);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A registry that binds no index and verifies nothing. */
|
||||||
|
private static final class EmptyRegistry implements PqSignerRegistry {
|
||||||
|
@Override
|
||||||
|
public Address addressForIndexAtHistoric(final long blockNumber, final int validatorIndex) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Address addressForIndexAtOwnHead(final long blockNumber, final int validatorIndex) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean verifyAtHistoric(
|
||||||
|
final long blockNumber, final int validatorIndex, final Bytes message, final Bytes signature) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean verifyAtOwnHead(
|
||||||
|
final long blockNumber, final int validatorIndex, final Bytes message, final Bytes signature) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Proposal unsealedProposal() {
|
||||||
|
final QbftBlockHeader header =
|
||||||
|
new QbftBlockHeaderTestFixture().number(round.getSequenceNumber()).buildHeader();
|
||||||
|
final QbftBlock block = new QbftBlockTestFixture().blockHeader(header).build();
|
||||||
|
return messageFactory.createProposal(round, block, Optional.empty(), emptyList(), emptyList());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void withoutEnforcementAnUnsealedProposalISValid() {
|
||||||
|
final ProposalPayloadValidator validator =
|
||||||
|
new ProposalPayloadValidator(proposer, round, null, null);
|
||||||
|
assertThat(validator.validateWithoutBlockValidation(unsealedProposal().getSignedPayload()))
|
||||||
|
.isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void withTheEnforcementARMEDTheSameProposalISRefused() {
|
||||||
|
// THE SAME message as above. The only difference is the hook, so a different result means it
|
||||||
|
// really is called. If this still returned true, the enforcement would be dead code.
|
||||||
|
final ProposalPayloadValidator validator =
|
||||||
|
new ProposalPayloadValidator(
|
||||||
|
proposer, round, null, new PqProposalEnforcement(HEIGHT, new EmptyRegistry(), 2800L));
|
||||||
|
assertThat(validator.validateWithoutBlockValidation(unsealedProposal().getSignedPayload()))
|
||||||
|
.isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void withTheEnforcementBELOWItsHeightTheSameProposalISValid() {
|
||||||
|
// The third state, closing the last way of being wrong: a hook that refused regardless of
|
||||||
|
// height would make the binary impossible to deploy. Here the enforcement exists but does not
|
||||||
|
// apply yet.
|
||||||
|
final ProposalPayloadValidator validator =
|
||||||
|
new ProposalPayloadValidator(
|
||||||
|
proposer,
|
||||||
|
round,
|
||||||
|
null,
|
||||||
|
new PqProposalEnforcement(HEIGHT + 1, new EmptyRegistry(), 2800L));
|
||||||
|
assertThat(validator.validateWithoutBlockValidation(unsealedProposal().getSignedPayload()))
|
||||||
|
.isTrue();
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user