Compare commits
No commits in common. "a7b24b8f307584b80f20ab73e57c7aef846a544c" and "39d3275e1797da778e390d84f8041846605a8d3b" have entirely different histories.
a7b24b8f30
...
39d3275e17
@ -1,134 +0,0 @@
|
||||
Per-file SHA-256 of the published anchor overlay.
|
||||
Generated by aerenew/publish-bundle/stage-anchor.cjs from the authoritative overlay.
|
||||
Aggregate hashes are not given on purpose: they depend on the locale of the machine that
|
||||
computed them, and the same 276 files once produced two different aggregates here.
|
||||
|
||||
329e4e2f93143cc8af092f0a951fa23619c12c4ec1793b053654f8e26e088da0 app/src/main/java/org/hyperledger/besu/cli/BesuCommand.java
|
||||
3f289d74bb1878280a99742320d7a27c19146a2f19cdf6a0857f2aad4361ad5e app/src/main/java/org/hyperledger/besu/cli/options/AerePqEmergencyOptions.java
|
||||
51764581814253cf76fa631e2d6557581a7bf9ae585a701009e37b80345f8867 app/src/main/java/org/hyperledger/besu/controller/QbftBesuControllerBuilder.java
|
||||
129493150c0b13e8020bdf50c0c1ae07268d4f73a69a821b303bf4894451fa5e app/src/test/java/org/hyperledger/besu/cli/options/AerePqEmergencyOptionsTest.java
|
||||
728c29c299edf6011f2c2798cbe930a043112e0e06b924e7edb54284ca44a758 config/src/main/java/org/hyperledger/besu/config/JsonGenesisConfigOptions.java
|
||||
a3b93f2602e9755d91358c9b3f473235f4d40ae23d912f797cdb03ed7e1348f9 consensus/common/build.gradle
|
||||
657b2c652c7995976acab3feca7fec5e15acc66cdfe1d5cc23ced6813db17a5f consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/BftBlockInterface.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
|
||||
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
|
||||
e8c8111a343cc993b950ac4e5b3992f16b8ee3d34c8827f00dd80d017884335b consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/HybridSealProducer.java
|
||||
03043d2360c35de3b6f27d029b7ee83cb6c964f4b827a504799607b8bfe1cdcc consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/HybridSealSupport.java
|
||||
65e33e639c58adb30045e88c770460757305acab31ac884e4c6b36f1c410ef12 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/HybridSignerRegistry.java
|
||||
e9b30713dea69601dd29b3e460d81585a21f5c48286d18f4d3bc5d4c4ec83c19 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchor.java
|
||||
852113d420f2c6b82945423a18591c3b2bd1018febe1a52e8b4e42a996c85114 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorConfig.java
|
||||
c6285ca43331781e1d350a07d58256d02b8b3a74b96b6469c0dca33ecfb6c8cd consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorLapse.java
|
||||
f27952a25bad02d2939a356f1bac8800ff8fb08023c6bdfb1c6d0e51b5a206da consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorNotReadyException.java
|
||||
81ddbf2b40f79bf0e382c507235f07ad74937ffd455e387c265880bdcf4e2b5c consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorSyncModeGuard.java
|
||||
e16b2c654fe21893c7972dac71f3ffafd1e4e8efbecdb6f95051820e36fbd48e consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorThresholdGuard.java
|
||||
91755c5a013b287d83d5e4d2d32ca820bced6d1d8560f0a3c76418797f0e69fe consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorV2.java
|
||||
78ecea21ff746564984dff40e1067808789f9bc5075ca3b5e8af92e0ea137c72 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqRegistryBinding.java
|
||||
f67a205036550f7d0f1bfed2abe7b89abd73e80614adff8d696b33c580269a6f consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqRegistryHash.java
|
||||
3fbd850e00e487c40c61310d894d6e1f765e9098e5c55438f9b06ab8fb92d45b consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqRegistryHashTool.java
|
||||
e85f4be696700184f9700cc531b4f3df313e9fb8cff8e2bc39d24241a769addb consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqSchemeSchedule.java
|
||||
d2e8b34f29d0dd83a66b78b53ce430404834cdf9ba334abeb72481d5d681aeb9 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqSealCache.java
|
||||
e8f7a8c4d8f1626d8efd167e5096de14ca17d40af58577737c5ef6a769dcc4d5 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqSealStore.java
|
||||
a235b3ec8c69665c525d7c74a084d589904879f5cd33cb87e1d1f38eabba8a15 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqSignerRegistry.java
|
||||
addba40c0d931a3ecfa3b2f0179311dacaa604884e3ebb1d79f958bd913f72e2 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/SchemeSeal.java
|
||||
285f6a7c1188a387fb1ac21f5ab809595ef48d936b40aced94047525e6def0a6 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/SealScheme.java
|
||||
352ffd303d2fcdc0d09ab943cebb2cd22b9c6329e129eb522f51fed8357f877d consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/SealSchemes.java
|
||||
1ef188292858db2a6b70c074a2477223bf96e2762a3711c8a993404eb2db2cfa consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/SlhDsaSealScheme.java
|
||||
b6c9ecbf3cd2ee73111984cd04a89c32ee56a1cc699a197b65ef7c10001b8cbc consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/blockcreation/BftBlockCreatorFactory.java
|
||||
5c8861eba1ea697d8deb88139d92c6ea6636b29d76c881b13e7f6c841ba2324f consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/blockcreation/PqAnchorProducer.java
|
||||
6ace00e18914a1558563e689b7427654b5e6226a31447bef1d0416d993f78840 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/tools/PqRegistryHashTool.java
|
||||
fab7a67ca190e6cb2469bac842112116208ea5e84e47b064d6617c36be14ae23 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D078ThresholdReachabilityTest.java
|
||||
5534cfab3bd59968823265655351491abef8ea73f594677a62bf47c8e88deb54 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D078ValidatorSetChangeTest.java
|
||||
da249c59f356e06928f73543d5529ae1911e4613a8125477f475b1dd54daed62 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D079ForkArmingTest.java
|
||||
9a794d7a4010ff5c561008229cb2ae97d5f79367641190c97cde8802b83daa72 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D081RegistryRotationTest.java
|
||||
a4c979fcd296f974c58b372d763b2bbf7fa96997f0fd52d66e5ac14185d5b8ab consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D140FleetRestartArmingTest.java
|
||||
2e81e02a419cdb2053b67e2bc9e699af1366955fa18d351178637b64a48a27be consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D141SealPersistenceTest.java
|
||||
42dd6396583aa19475e23c9781f568958d9789f0dba084dc63ac9833203f3f1f consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D146ArmingGateTest.java
|
||||
75fca200a2ac9e7226c70d96916a4ae33201a8acb466d0fdc591576e09edb3fc consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D147InertBinaryTest.java
|
||||
acd5c1e7f49dbbf444c19c91386bbd03b9298d952d2d13721793670103e71ee9 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D177NeutralNamesTest.java
|
||||
bc6b58570f835276324a5bd2704e7de8cac4e0693bd0138199d149c1ec7d4174 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D2CallerIntentTest.java
|
||||
4aeab501c83f0d7fbe7a45c99e2035fd034b055b2a6cd5925026e29e4e427849 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D2RegistryHeightRefusalTest.java
|
||||
c574ec22e642ac464b1728d27e4bb26202448a45c044f08935cc0a0efa0297d9 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/FalconAttachIntervalTest.java
|
||||
0eb20506f851c1510bcaf659db8c5b0d384cdc60ed06192a03a665f0ee2b3912 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/HybridSealProducerTest.java
|
||||
186c4c92199400783ee3424231818dd440b0da41aeb3d8bd643cd13759998250 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/HybridSealSupportTest.java
|
||||
834861639fd119c1653e6a3977ffbe4c2d4dce63a5741f1fe5a087643b86ed74 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/HybridSignerRegistryTest.java
|
||||
220fceda0f5292054e322bb7df2d5258905a8a7b6f9febd802789d3946a0c01c consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorConfigTest.java
|
||||
6e2ae09f62765d6e558fdf56a124c890681c2b2c3d410bf8aa497820cd76c3a5 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorEmergencyConfigTest.java
|
||||
7470d72271dd4f9de96094b3d1c6dc4b5b5ea62e46a058d4e154e3742d3ccef1 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorIntervalTest.java
|
||||
e2487ff508ffb51bb61ca19531f4c4e68c5981611fd9fec5c92a2965f0bb8f47 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorLapseTest.java
|
||||
dabb059478da5259c309e6637e987ab35ef7b69cc286e41f936e4f3e83d3f082 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorMinSealsFloorTest.java
|
||||
c20743ce1b54af2b7b9d42366bef14678fd94aa8d1dc98eabf21ff0f9ae15302 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorProducerCacheHygieneTest.java
|
||||
0bf1c8a9cdd91d7c34053d6e5b4ff929787f34eb7aa7d6eed26fc5c94fa1c42b consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorProducerCostTest.java
|
||||
23daeb4888c8de8337518a27ef60e42573ebafaf340075e8ce9236f0d8d580f6 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorSealCapTest.java
|
||||
09c41c77408acc4711e4c46175485bf41babff0d0a8ec7481670fddfd28967b0 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorTest.java
|
||||
6ff9586cf0d544590585ad27227f7775f1dd86c61ccdd475321180d4e53f47d2 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorThresholdGuardTest.java
|
||||
72e58681267664a864dba5a371ffbc6f296b524c2dee248893b17c12da113f00 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorV2Test.java
|
||||
49c95a24ce4890fe79b9167c196ba8da460e03bdddfee5652944e130fed8ee78 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqArmingGateTest.java
|
||||
74e2629acdcf9242c32340679914fa40f50d0a9479c83018b9b7a0733397a843 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqCallerIntentTest.java
|
||||
067a3027f21681d82b4bf7ac34ff1590f37213152ead9d246c6d45162e5a2d0b consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqFleetRestartArmingTest.java
|
||||
cb674b2461f2527cea044722273972d0e8cdac61d6f8b6a7fab3b875abf05a6b consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqForkArmingTest.java
|
||||
64db7075e47fb32b4756a43cf218bfcec46e915eaadeb10f5f04716c6f615d66 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqForkThresholdReachabilityTest.java
|
||||
7da106ab48ef4145fa73de5ce817210b5320ccdf06433a688e1a2f9ee69ee53a consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqForkValidatorSetChangeTest.java
|
||||
b28edbcd65bb9228d1a36589415eb803871cec6e9f2d7a247bc45a082f3b7721 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqInertBinaryTest.java
|
||||
f260d284f936307ac4c31142cbea0359ded1197880b8a960ac27dd7beb5c3b53 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqParentHeightAlignmentTest.java
|
||||
19ff0e11861d44b582fe39d09aaeb63ee0a5b9ba07d036da85a70ea612dab6dc consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqRegistryBindingTest.java
|
||||
97281811ae1fe6fae8001d0d64e11d4779be153547ab24c779a54aba3e211c88 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqRegistryHeightRefusalTest.java
|
||||
7ec10b4fa5ac09e4d980f5d180c75eecc892d95c2f81d97c6140e64ada34654d consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqRegistryRotationTest.java
|
||||
36cd369e8386cac85501f7d8a56f9604ec39e8bd89a8d754cabe85e4fb6b8e9a consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqSchemeScheduleTest.java
|
||||
44b0010772765eacb8fb720cc0570b0a8f681a3a329060271e67b7c518ddb25b consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqSealPersistenceTest.java
|
||||
e0bc7a465dbb2dd7efa5f1830211a1a45c6554c300b2088dd70bb9677b0ac217 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqSignedHeightTest.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
|
||||
56426ba6bb10df29f5ef5f5959b1646dda5a6d9e7db9904ceb7ad1070ad750b8 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PreparePqAttachGateTest.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
|
||||
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
|
||||
37bda0f126c1333cf854a33c64cc98cedcdc8c0c2ec4cc3ba1f6736e7302ddff 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
|
||||
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
|
||||
07834160f12c4f28c567959c358176708b17742cc7cc4f49542b165efef5f1c8 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
|
||||
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
|
||||
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
|
||||
60eb9c5a8e3f464aa42915909e402bf59af9bad4e28631538dcf2f5ca52a9569 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/PrepareValidator.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
|
||||
0bb2c1e31a7f29e9b07fb7030567746fc6d79460ed4739d5ecafac44501f7bb5 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/statemachine/PqLateSealSalvageTest.java
|
||||
8c40173c7b248edec7c29ee9e049f9e269f4725b637bceb8734735fc73eb9ca2 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/CommitValidatorPqEnforcementTest.java
|
||||
401c25f63abb248dfe60b69676a34ea255bb669b4bc58e45916f6eecea3cb870 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqCommitEnforcementTest.java
|
||||
c0853ed97c53d54951e25ad6d0b70c0299dedd859cc7c44da64e3751d0e0de33 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqCommitPlumbingTest.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
|
||||
1939e33cc8ea81782e5e17d68e2b59c001e4207ed8ef07acd1d2a166a047dcb3 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqPrepareEnforcementTest.java
|
||||
dc9f9e862a11f0135d26976176a1d1adac3e84fd5f1e06572d8727262cb3374f consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PrepareValidatorPqWiringTest.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
|
||||
7d0a75818ac64b601b79c32b8289eedda8e2300dbb0f5e9186084f4e01b04d26 consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/QbftExtraDataCodec.java
|
||||
47fbdd8639c464bf19bac3a0ba540af4d99f79e9df02c2ad586a19a2aebd037d consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/adaptor/QbftBlockCreatorAdaptor.java
|
||||
cfb0aad408ab620f28b09914ece7d8d3d09f9e6bbb503070e28d9ef39c023dee consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/blockcreation/QbftBlockCreatorFactory.java
|
||||
cbec6f4e280250ddefe5c79c453b59a07be6435e6f88d5614e92168c855e4693 consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/AereBaseFeeImportRule.java
|
||||
e4e75c28ff15d058176145b1ae32606bf91b22112d1668a718f1a6ae4add0e71 consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/FalconSealValidationRule.java
|
||||
ab0fcd8722dcb76560f0ef8fda8af9c2b8b6ec9b468326aaf616c853526f7f81 consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorDigestAttachedRule.java
|
||||
81a46071e77eb72aac107e6afca9e50612a81b66c5f17999815652eeda005248 consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorDigestRule.java
|
||||
05ee2e97644c9a79ee082a5030b6e2a6745933d26cb8238a6339e6006c47d7e3 consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorSealsRule.java
|
||||
2add2a7733668e11b50ddd64b05bb37db26a18284a636bbe720c2cb1f6aa28ed consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqEmergencyShoutRule.java
|
||||
8ac99e94c89f61f0281199cf369e282fc4f9fb1a3414a1650766e1d7548e76f5 consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqRegistryBindingRule.java
|
||||
52b56f157500ae3527b7e55c51786915bb5d6980065668f1e7b8e297bf7125ef consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/QbftAnchorRuleWiringTest.java
|
||||
5a18c7fee308654d9557fc507ca7a64704bdac5d13835e7df47a3dfb41519902 consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/AereBaseFeeImportRuleTest.java
|
||||
8f27193a286d1e6bb4c84f98e5af9821ee9a22873a26288fad67256c710a51cd consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/D078GateFeedTest.java
|
||||
ebc5811c3a765b1175023d2c767eb8c71f4f2bdbb63ec0b354294a3f0ac15dfa consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/D079ArmedWithoutRegistryTest.java
|
||||
ad018cba0a3fe7f018b11c6c6a2d45e3f5547342ec45b620a4df2595801ae71a consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/FalconSealLogThrottleTest.java
|
||||
3895d10bcf5ffbdaf0506503a0d9e3d72c600288def268fd5e9f68c4a042e162 consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/FalconSealValidationRuleRetirementTest.java
|
||||
e2df575ee4d6ab5bd961b0886ece3d3c392a50193f1d3256a438a72da9e20d10 consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorDigestRuleTest.java
|
||||
77d16fd35ca5607e9702f39cfd9a24a7e8523f631a73e82138763e6d5b718e7b consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorDisarmedWindowTest.java
|
||||
a81da71dd34dd111a0da43c22d22345dcc595c084a2c2d36aec1f45d8b762f80 consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorSealsRuleTest.java
|
||||
535cca4f1083c8a25bd29877e988b99704666853c1fe8b0ba32d71efe4e15f37 consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorTestSupport.java
|
||||
321cb7e6923f77e078b523316650ac49136cf8d4df506b9bb235a32795c69611 consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqArmedWithoutRegistryTest.java
|
||||
484bc74ff3d1e52d25631264d83af70c2b695e973c7360b0f3db471ab5c6613a consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqEmergencyShoutRuleTest.java
|
||||
49c3fd97e7985966531928af173b6f38ac48ff9dc8b5be1106e5b7bfb9c7b2b6 consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqForkGateFeedTest.java
|
||||
3b690b72e80a0f9eaf868a3fa3f99ace42dad74dc30b2c98b9f3ca5a023917c0 ethereum/eth/src/main/java/org/hyperledger/besu/ethereum/eth/sync/DownloadHeadersStep.java
|
||||
@ -52,8 +52,7 @@ The threat is not ours to claim. It is the long-range attack, and the peer-revie
|
||||
Azouvi, Danezis and Nikolaenko, "Winkle: Foiling Long-Range Attacks in Proof-of-Stake Systems"
|
||||
(IACR 2019/1440; AFT 2020, pp. 189-201). Two things should be said plainly about that citation,
|
||||
because we checked it rather than repeated it. **Winkle does not mention quantum adversaries at
|
||||
all**: it treats the case where a validator's old signing keys reach an adversary, by any
|
||||
means. The quantum framing is
|
||||
all**: it treats old validator keys becoming compromised, by any means. The quantum framing is
|
||||
ours. And **Winkle's own defence is not ours**: it adds a secondary layer of client-based
|
||||
validation, where clients sign a hash of the previously sequenced block. A second published defence
|
||||
for the same threat, Azouvi and Vukolic, "Pikachu: Securing PoS Blockchains from
|
||||
@ -123,45 +122,6 @@ now pinned: change one byte of it and the digest no longer matches, and the head
|
||||
|
||||
## What is in here
|
||||
|
||||
Updated 2026-08-29. The first publication of this directory, on 2026-08-11, was staged by hand and
|
||||
was never refreshed: by 29 August, 63 of the 78 files here had changed in the tree we actually run
|
||||
and 50 files had never been published at all. That is fixed at the root rather than by one copy —
|
||||
`stage-anchor.cjs` in our repository derives this directory from the authoritative overlay, refuses
|
||||
to run if the two files this README tells you to read first are missing, deletes what we removed
|
||||
rather than letting the published set grow monotonically, and stops on the secret-scanner's verdict
|
||||
instead of around it. The per-file SHA-256 list is in `MANIFEST-sha256.txt`.
|
||||
|
||||
**Three of the files in the overlay are not here, and it is the same three every time**: the
|
||||
negative-control harnesses. They plant a defect on purpose to prove a guard can fail, so they are a
|
||||
recipe for disabling a guard rather than a description of one. Everything they prove is stated in
|
||||
"What is proven, and by what" below, and every guard they exercise is here.
|
||||
|
||||
### What is armed on chain 2800, and what is only present
|
||||
|
||||
This matters more than the file list, so it is stated before it.
|
||||
|
||||
| Layer | In this directory | Armed on chain 2800 |
|
||||
|---|---|---|
|
||||
| Anchor certificate under the block hash | yes | **yes**, since block 13,014,000 |
|
||||
| Enforced minimum seals at an anchor height | yes | **yes**, 6 of 9 since 14,961,456 |
|
||||
| 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, enforcement | yes | **no** — no node sets the property |
|
||||
| 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
|
||||
are unset, and unset means never. Each refuses loudly on a value it cannot parse rather than booting
|
||||
a node that believes itself armed — a node that disarms itself because of a mistyped character looks
|
||||
exactly like a correctly configured one, right up to the day it matters. The tests for that
|
||||
behaviour are in this directory and they are the ones to read if you doubt the claim.
|
||||
|
||||
The PREPARE layer is newer than the anchor and stronger where it applies: an armed node that refuses
|
||||
unsealed PREPAREs never reaches the prepared state, so it never sends COMMIT at all. That also means
|
||||
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.
|
||||
|
||||
### The files
|
||||
|
||||
- `consensus/common/.../bft/` — the anchor itself: configuration, the digest, the seal cache and
|
||||
store, the producer that attaches seals, the Falcon registry that maps a validator to a key.
|
||||
- `consensus/qbft/.../headervalidationrules/` — the validation rules: the digest must match, the
|
||||
@ -213,14 +173,6 @@ and the validation rules.
|
||||
upgraded every node.
|
||||
- **Nothing here demonstrates what is configured on any live network.** These files show what the
|
||||
code does when armed. They are not evidence about any running fleet, and should not be read as any.
|
||||
- **The PREPARE layer has not run on a live network.** It has been exercised on a test network,
|
||||
including a mixed run against a second, independent client implementation, and it has not been
|
||||
armed on chain 2800 or anywhere else that carries value. Test-network evidence is evidence about a
|
||||
test network.
|
||||
- **The hybrid Falcon + SLH-DSA certificate has never been signed with a real key.** The scheme
|
||||
layer is here and a second algorithm passes through the same consensus code untouched, which is
|
||||
what the tests measure. Generating hybrid validator keys is a separate decision that has not been
|
||||
taken, so no hybrid certificate exists on any chain.
|
||||
|
||||
## One claim we retracted, on purpose
|
||||
|
||||
@ -237,22 +189,10 @@ sentence. Someone else has to do the verifying, which is why it is here.
|
||||
|
||||
## Terminology, used precisely
|
||||
|
||||
**A certificate at an anchor height is signed by a quorum of six of nine validators, since block
|
||||
14,961,456 (August 21, 2026). It was f+1 before that, and this paragraph said so.** The distinction
|
||||
is worth keeping in view rather than deleting, because it is the difference between two genuinely
|
||||
different claims. f+1 signatures guarantee only that at least one honest validator signed. A quorum
|
||||
of 2f+1 is the same count QBFT itself requires to agree on the block, so from 14,961,456 the
|
||||
post-quantum certificate at an anchor height is backed by as many validators as the block itself.
|
||||
|
||||
Two things follow, and both should be said rather than left to be discovered. The word "quorum"
|
||||
applies **only at anchor heights** - about every 32nd block; the blocks between them carry no Falcon
|
||||
seals at all and are finalized by ECDSA alone, so "a post-quantum quorum on every block" would be
|
||||
false. And the higher threshold costs liveness margin: at K=6 of nine, three late validators are
|
||||
enough to stall an anchor block, where K=3 tolerated six. That trade was made deliberately, and it
|
||||
is the reason the threshold is a configured schedule rather than a constant.
|
||||
|
||||
Anyone counting will notice which of the two regimes a given height falls in, and they should. The
|
||||
schedule is in the header of this document and on every node.
|
||||
**A certificate here is signed by f+1 validators, not by a quorum.** With f Byzantine faults
|
||||
tolerated, f+1 signatures guarantee that at least one honest validator signed. That is a real
|
||||
property and it is not the same as a quorum, and we do not call it one. Anyone counting will notice,
|
||||
and they should.
|
||||
|
||||
The seal threshold is a **floor, not a cap**: nodes attach as many verified seals as arrive in time,
|
||||
which is at least the threshold and often more. A separate cap bounds how many are written, because
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright contributors to Besu / AERE Network.
|
||||
* Copyright contributors to Besu / Aere Network.
|
||||
*
|
||||
* 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
|
||||
@ -32,9 +32,10 @@ import picocli.CommandLine;
|
||||
* If the anchor misbehaves on the live chain at three in the morning, the person on the other end of
|
||||
* the page has to be able to stand it down with a RESTART. Until this class existed the controls
|
||||
* were real but reachable only as system properties and environment variables, which in practice
|
||||
* means editing a systemd unit or a wrapper script on seven machines under time pressure, in a file
|
||||
* whose syntax nobody remembers, with no {@code --help} to check against. Two of these options were
|
||||
* already named in the javadoc of {@code PqAnchorConfig} as though they existed. They did not.
|
||||
* means editing a service unit or a wrapper script on every node of the fleet under time pressure,
|
||||
* in a file whose syntax nobody remembers, with no {@code --help} to check against. Two of these
|
||||
* options were already named in the javadoc of {@code PqAnchorConfig} as though they existed. They
|
||||
* did not.
|
||||
*
|
||||
* <p><b>The three controls, and why exactly these three.</b>
|
||||
*
|
||||
@ -48,10 +49,10 @@ import picocli.CommandLine;
|
||||
* one to reach for when the chain has stalled because proposers cannot gather K seals - too
|
||||
* many validators down, a key rotation half-done - and the rest of the scheme is fine.
|
||||
* <li>{@code --Xaere-pq-registry-mismatch-allow} lets a node START and KEEP RUNNING with a Falcon
|
||||
* registry that does not satisfy what genesis requires. This is the way back from the A8
|
||||
* guard, which is a CONFIGURATION guard: one wrong byte in a registry file pushed to the fleet
|
||||
* stops every node it reached, for a reason that has nothing to do with whether the blocks are
|
||||
* valid.
|
||||
* registry that does not satisfy what genesis requires. This is the way back from the
|
||||
* REGISTRY BINDING guard, which is a CONFIGURATION guard: one wrong byte in a registry file
|
||||
* pushed to the fleet stops every node it reached, for a reason that has nothing to do with
|
||||
* whether the blocks are valid.
|
||||
* </ul>
|
||||
*
|
||||
* <p><b>Every one of them shouts.</b> A quiet way out is worse than no way out, because it will be
|
||||
@ -64,10 +65,10 @@ import picocli.CommandLine;
|
||||
* <p><b>How they take effect, and why through the properties.</b> Each option writes the SAME system
|
||||
* property the control has always read, before anything reads it. That is deliberate: it leaves
|
||||
* exactly one place where each decision is made, so the command line cannot mean something subtly
|
||||
* different from the environment variable, and the code that was measured under A8 and under the
|
||||
* anchor work is the code still doing the deciding. Precedence is command line, then system
|
||||
* property, then environment variable; which source won is written into the log so an operator never
|
||||
* has to guess whether the flag took.
|
||||
* different from the environment variable, and the code that was measured under the registry
|
||||
* binding work and under the anchor work is the code still doing the deciding. Precedence is
|
||||
* command line, then system property, then environment variable; which source won is written into
|
||||
* the log so an operator never has to guess whether the flag took.
|
||||
*
|
||||
* <p><b>Deliberately LOCAL, not on-chain.</b> A halted chain cannot deliver a height-scheduled
|
||||
* configuration change. The only control that works when the chain is ALREADY STOPPED is one that
|
||||
|
||||
@ -264,8 +264,7 @@ public class QbftBesuControllerBuilder extends BesuControllerBuilder {
|
||||
// eligible Falcon seal(s) ... threshold is 3", over "Attachment stays OFF (fail-safe)".
|
||||
// With K=0 the chain heals itself. With K>0 it NEVER heals.
|
||||
//
|
||||
// A simultaneous restart of the whole fleet is not an exotic scenario: it is a power cut, a
|
||||
// scheduled
|
||||
// A simultaneous restart of the seven is not an exotic scenario: it is a power cut, a scheduled
|
||||
// kernel update, or any procedure that starts the fleet all at once.
|
||||
//
|
||||
// The repair invents nothing and weakens no check: it does here, once, exactly what the import
|
||||
@ -311,7 +310,7 @@ public class QbftBesuControllerBuilder extends BesuControllerBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
// AERE A8 (2026-08-01): bind the Falcon registry to consensus.
|
||||
// AERE REGISTRY-BINDING (2026-08-01): bind the Falcon registry to consensus.
|
||||
//
|
||||
// Deliberately placed immediately after the attachment guard and before BftExecutors, for the
|
||||
// same reason: a chain head exists here, and the network and the QBFT state machine have not
|
||||
@ -322,13 +321,13 @@ public class QbftBesuControllerBuilder extends BesuControllerBuilder {
|
||||
// The two guards answer different questions - "is the activation height sane relative to this
|
||||
// chain" and "is this the registry this chain requires" - and both have to be true.
|
||||
//
|
||||
// AERE A8 (2026-08-02): the SCHEDULE comes from the genesis configuration BESU BOOTED WITH, not
|
||||
// from a genesis file re-opened by path from a system property. Re-reading a file would have
|
||||
// reproduced the defect one level up: the enforced binding would again depend on a local file a
|
||||
// node can be pointed at wrongly, and a node reading a stale copy would enforce a stale
|
||||
// schedule, or none, in silence. Read from GenesisConfigOptions there is no second file: the
|
||||
// value enforced comes out of the same object that produced this node's genesis hash, so a node
|
||||
// that disagrees about the schedule already disagrees about the chain.
|
||||
// AERE REGISTRY-BINDING (2026-08-02): the SCHEDULE comes from the genesis configuration BESU
|
||||
// BOOTED WITH, not from a genesis file re-opened by path from a system property. Re-reading a
|
||||
// file would have reproduced the defect one level up: the enforced binding would again depend
|
||||
// on a local file a node can be pointed at wrongly, and a node reading a stale copy would
|
||||
// enforce a stale schedule, or none, in silence. Read from GenesisConfigOptions there is no
|
||||
// second file: the value enforced comes out of the same object that produced this node's
|
||||
// genesis hash, so a node that disagrees about the schedule already disagrees about the chain.
|
||||
//
|
||||
// MEASURED, and it is the input that decides the hash: the live chain 2800 genesis carries
|
||||
// config.chainId = 2800, so getChainId() is PRESENT and the 0L fallback below is not the value
|
||||
@ -379,12 +378,13 @@ public class QbftBesuControllerBuilder extends BesuControllerBuilder {
|
||||
// with zero seals, nobody reached K, nobody could propose, and so nobody sent another Commit.
|
||||
// The same circular deadlock, one level down.
|
||||
//
|
||||
// WHY THIS IS NOT A8 IN NEW CLOTHES, and this is the whole security argument: the seal is
|
||||
// SELF-VERIFYING. Every seal read from the file is cryptographically verified again against the
|
||||
// anchored registry, over an M rebuilt from the head header this very process has just loaded,
|
||||
// exactly as the producer does at selection time. A forged file cannot inject a seal without
|
||||
// forging a Falcon-512 signature; all it can obtain is the empty cache an absent file already
|
||||
// gives. A8 was a REGISTRY of keys trusted because it sat in a file.
|
||||
// WHY THIS IS NOT A REGISTRY-TRUSTED-FROM-A-FILE IN NEW CLOTHES, and this is the whole security
|
||||
// argument: the seal is SELF-VERIFYING. Every seal read from the file is cryptographically
|
||||
// verified again against the anchored registry, over an M rebuilt from the head header this very
|
||||
// process has just loaded, exactly as the producer does at selection time. A forged file cannot
|
||||
// inject a seal without forging a Falcon-512 signature; all it can obtain is the empty cache an
|
||||
// absent file already gives. The defect back then was a REGISTRY of keys trusted because it sat
|
||||
// in a file.
|
||||
//
|
||||
// Deliberately here: the registry is already armed by the block above (otherwise no seal could
|
||||
// resolve and the restore would have gone quiet for nothing), the chain head exists, and the
|
||||
@ -684,8 +684,8 @@ public class QbftBesuControllerBuilder extends BesuControllerBuilder {
|
||||
}
|
||||
|
||||
/**
|
||||
* AERE A8: read a genesis {@code config.*} value that Besu itself does not model, out of the
|
||||
* genesis configuration THIS NODE BOOTED WITH.
|
||||
* AERE REGISTRY-BINDING: read a genesis {@code config.*} value that Besu itself does not model,
|
||||
* out of the genesis configuration THIS NODE BOOTED WITH.
|
||||
*
|
||||
* <p>Besu's {@code GenesisConfigOptions.asMap()} cannot be used for this: it is an allow-list of
|
||||
* the keys Besu knows about, so a key of ours is simply absent from it and the guard would read
|
||||
@ -705,9 +705,10 @@ public class QbftBesuControllerBuilder extends BesuControllerBuilder {
|
||||
private com.fasterxml.jackson.databind.JsonNode aereGenesisConfigNode(final String key) {
|
||||
if (!(genesisConfigOptions instanceof JsonGenesisConfigOptions)) {
|
||||
LOG.warn(
|
||||
"AERE PQC A8: the genesis configuration is a {}, not the JSON-backed implementation, so "
|
||||
+ "config.{} cannot be read and the Falcon registry binding is NOT ENFORCED on this "
|
||||
+ "node. A binding everybody believes is on and is not is worse than no binding.",
|
||||
"AERE PQC REGISTRY-BINDING: the genesis configuration is a {}, not the JSON-backed "
|
||||
+ "implementation, so config.{} cannot be read and the Falcon registry binding is "
|
||||
+ "NOT ENFORCED on this node. A binding everybody believes is on and is not is worse "
|
||||
+ "than no binding.",
|
||||
genesisConfigOptions.getClass().getName(),
|
||||
key);
|
||||
return null;
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright contributors to Besu / AERE Network.
|
||||
* Copyright contributors to Besu / Aere Network.
|
||||
*
|
||||
* 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
|
||||
|
||||
@ -647,7 +647,8 @@ public class JsonGenesisConfigOptions implements GenesisConfigOptions {
|
||||
}
|
||||
|
||||
/**
|
||||
* AERE A8: the raw genesis {@code config.*} value for a key Besu does not model, or null.
|
||||
* AERE REGISTRY-BINDING: the raw genesis {@code config.*} value for a key Besu does not model, or
|
||||
* null.
|
||||
*
|
||||
* <p>WHY THIS EXISTS. {@link #asMap()} is an allow-list of the keys Besu knows, so a key of ours
|
||||
* is absent from it, and a guard reading it would conclude "no schedule" on a genesis that
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright contributors to Besu / AERE Network.
|
||||
* Copyright contributors to Besu / Aere Network.
|
||||
*
|
||||
* 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
|
||||
|
||||
@ -1,104 +0,0 @@
|
||||
/* AERE crypto-agility: Falcon-512 behind the SealScheme seam. The registry form is the raw
|
||||
* Falcon h vector, 896 bytes, exactly what the signer registry stores today (measured on the
|
||||
* proof-network registry files, registru-PROBA-v2-*.properties: 896 per entry). The 897-byte
|
||||
* form pk(897) = 0x09 || h belongs to the 0x0AE1 PRECOMPILE input format, one header byte above
|
||||
* this layer; confusing the two costs a red test, which is exactly how this comment was earned. */
|
||||
package org.hyperledger.besu.consensus.common.bft;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.bouncycastle.pqc.crypto.falcon.FalconKeyGenerationParameters;
|
||||
import org.bouncycastle.pqc.crypto.falcon.FalconKeyPairGenerator;
|
||||
import org.bouncycastle.pqc.crypto.falcon.FalconParameters;
|
||||
import org.bouncycastle.pqc.crypto.falcon.FalconPrivateKeyParameters;
|
||||
import org.bouncycastle.pqc.crypto.falcon.FalconPublicKeyParameters;
|
||||
import org.bouncycastle.pqc.crypto.falcon.FalconSigner;
|
||||
import org.bouncycastle.crypto.AsymmetricCipherKeyPair;
|
||||
|
||||
/** Falcon-512 as a pluggable seal scheme. */
|
||||
public final class FalconSealScheme implements SealScheme {
|
||||
|
||||
/** Registry form: the raw public h vector for Falcon-512 (no precompile header byte). */
|
||||
public static final int PUBLIC_KEY_LENGTH = 896;
|
||||
|
||||
private record Pub(FalconPublicKeyParameters params) implements PublicHandle {}
|
||||
|
||||
private record Priv(FalconPrivateKeyParameters params) implements PrivateHandle {}
|
||||
|
||||
@Override
|
||||
public String id() {
|
||||
return "falcon-512";
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte wireId() {
|
||||
return 0x01;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int publicKeyLength() {
|
||||
return PUBLIC_KEY_LENGTH;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<PublicHandle> parsePublicKey(final byte[] registryForm) {
|
||||
if (registryForm == null || registryForm.length != PUBLIC_KEY_LENGTH) {
|
||||
return Optional.empty();
|
||||
}
|
||||
try {
|
||||
return Optional.of(new Pub(new FalconPublicKeyParameters(FalconParameters.falcon_512, registryForm)));
|
||||
} catch (final RuntimeException e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<byte[]> sign(final PrivateHandle key, final byte[] message) {
|
||||
if (!(key instanceof Priv p) || message == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
try {
|
||||
final FalconSigner signer = new FalconSigner();
|
||||
signer.init(true, p.params());
|
||||
return Optional.of(signer.generateSignature(message));
|
||||
} catch (final RuntimeException e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean verify(final PublicHandle key, final byte[] message, final byte[] signature) {
|
||||
if (!(key instanceof Pub p) || message == null || signature == null) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
final FalconSigner verifier = new FalconSigner();
|
||||
verifier.init(false, p.params());
|
||||
return verifier.verifySignature(message, signature);
|
||||
} catch (final RuntimeException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Transition bridge for the live signing path: FalconSealSupport loads the node's private
|
||||
* key as BC {@link FalconPrivateKeyParameters} long before this layer existed. Routing its
|
||||
* signing through the scheme without re-plumbing key loading needs this one adapter. The BC
|
||||
* type appears ONLY here, in the class whose whole job is to speak Falcon. */
|
||||
public Optional<byte[]> signWithParams(final FalconPrivateKeyParameters key, final byte[] message) {
|
||||
if (key == null || message == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return sign(new Priv(key), message);
|
||||
}
|
||||
|
||||
@Override
|
||||
public GeneratedPair generate(final SecureRandom random) {
|
||||
final FalconKeyPairGenerator gen = new FalconKeyPairGenerator();
|
||||
gen.init(new FalconKeyGenerationParameters(random, FalconParameters.falcon_512));
|
||||
final AsymmetricCipherKeyPair pair = gen.generateKeyPair();
|
||||
final FalconPublicKeyParameters pub = (FalconPublicKeyParameters) pair.getPublic();
|
||||
final FalconPrivateKeyParameters priv = (FalconPrivateKeyParameters) pair.getPrivate();
|
||||
return new GeneratedPair(new Pub(pub), new Priv(priv), pub.getH());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,140 +0,0 @@
|
||||
/*
|
||||
* AERE HYBRID, the PRODUCER half (2026-08-25). The counterpart of PqCommitEnforcement: that one
|
||||
* decides what is accepted, this one decides what is EMITTED.
|
||||
*
|
||||
* WHY A SEPARATE CLASS FROM FalconSealSupport. Falcon has an old production path, with
|
||||
* per-component loading, startup guards and a singleton; widening it would have meant touching
|
||||
* the very class the live consensus hangs on, for a capability armed nowhere today.
|
||||
* Falcon is not touched here at all: this class produces ONLY the seals of the other schemes,
|
||||
* i.e. exactly the content of the extras slot in CommitPayload.
|
||||
*
|
||||
* THE EMISSION GATE IS WHY THIS CLASS IS ALLOWED TO EXIST. Adding extras changes the signed
|
||||
* bytes, so an older node can no longer PARSE the message. What protects the fleet is not
|
||||
* leniency at decode time, which cannot work, but the fact that nothing emits extras until the
|
||||
* attach height, the same discipline as the Falcon gate. Unset means: never emit,
|
||||
* EVER, and that is the default.
|
||||
*
|
||||
* HALF A CERTIFICATE IS NOT EMITTED. If the schedule requires a scheme this node has no key
|
||||
* for, no maimed certificate is sent (every neighbour would refuse it at quorum anyway):
|
||||
* nothing is sent, and the log SHOUTS. An operator must find out a key is missing
|
||||
* BEFORE the height where enforcement bites, not on that very day.
|
||||
*/
|
||||
package org.hyperledger.besu.consensus.common.bft;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
import org.apache.tuweni.bytes.Bytes;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/** Produces the non-Falcon scheme seals a hybrid commit carries, gated on height. */
|
||||
public final class HybridSealProducer {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(HybridSealProducer.class);
|
||||
|
||||
/** The disarmed attachment height: no block ever reaches it, so nothing is ever emitted. */
|
||||
public static final long NEVER = Long.MAX_VALUE;
|
||||
|
||||
private final long attachFromBlock;
|
||||
private final PqSchemeSchedule schedule;
|
||||
private final int validatorIndex;
|
||||
private final Map<String, SealScheme.PrivateHandle> localKeys;
|
||||
// one shout per missing scheme, not one per block: a permanent alarm is learned and ignored
|
||||
private final Set<String> alreadyShouted = new java.util.HashSet<>();
|
||||
|
||||
/**
|
||||
* @param attachFromBlock first height at which extras may be emitted; {@link #NEVER} to disarm
|
||||
* @param schedule which schemes are required at which height; null disarms as well
|
||||
* @param validatorIndex this node's index, written into every seal it produces
|
||||
* @param localKeys the private handles this node holds, per scheme id
|
||||
*/
|
||||
public HybridSealProducer(
|
||||
final long attachFromBlock,
|
||||
final PqSchemeSchedule schedule,
|
||||
final int validatorIndex,
|
||||
final Map<String, SealScheme.PrivateHandle> localKeys) {
|
||||
this.attachFromBlock = schedule == null ? NEVER : attachFromBlock;
|
||||
this.schedule = schedule;
|
||||
this.validatorIndex = validatorIndex;
|
||||
this.localKeys = localKeys == null ? Map.of() : Map.copyOf(localKeys);
|
||||
}
|
||||
|
||||
/** A producer that never emits anything: the configuration of every node today. */
|
||||
public static HybridSealProducer disarmed() {
|
||||
return new HybridSealProducer(NEVER, null, -1, Map.of());
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether extras may be emitted at this height at all.
|
||||
*
|
||||
* @param blockNumber the height
|
||||
* @return true when the attachment gate is open
|
||||
*/
|
||||
public boolean attachmentArmedAt(final long blockNumber) {
|
||||
return schedule != null && blockNumber >= attachFromBlock;
|
||||
}
|
||||
|
||||
/**
|
||||
* The extra scheme seals for this block, or an empty list.
|
||||
*
|
||||
* <p>Never throws: a producer fault must never take down the ECDSA commit path. Every refusal
|
||||
* is a logged reason plus an empty list, exactly the stance of the Falcon signer.
|
||||
*
|
||||
* @param blockNumber the height of the block being committed
|
||||
* @param message the very bytes the Falcon seal of this commit signs
|
||||
* @return the seals, or empty when the gate is shut, a key is missing, or signing failed
|
||||
*/
|
||||
public List<SchemeSeal> sealsFor(final long blockNumber, final Bytes message) {
|
||||
if (!attachmentArmedAt(blockNumber) || message == null) {
|
||||
return List.of();
|
||||
}
|
||||
try {
|
||||
final Set<String> required = schedule.schemesAt(blockNumber);
|
||||
final List<SchemeSeal> produced = new ArrayList<>();
|
||||
for (final String schemeId : required) {
|
||||
if (SealSchemes.FALCON_512.id().equals(schemeId)) {
|
||||
continue; // Falcon has its own slot and its own signer; never duplicated here
|
||||
}
|
||||
final Optional<SealScheme> scheme = SealSchemes.byId(schemeId);
|
||||
if (scheme.isEmpty()) {
|
||||
shoutOnce(schemeId, "the schedule names scheme '" + schemeId
|
||||
+ "' which this binary does not implement");
|
||||
return List.of();
|
||||
}
|
||||
final SealScheme.PrivateHandle key = localKeys.get(schemeId);
|
||||
if (key == null) {
|
||||
shoutOnce(schemeId, "this node holds NO " + schemeId
|
||||
+ " signing key, so it cannot produce the certificate the schedule requires from"
|
||||
+ " height " + blockNumber + " onwards");
|
||||
return List.of();
|
||||
}
|
||||
final Optional<byte[]> signature = scheme.get().sign(key, message.toArray());
|
||||
if (signature.isEmpty()) {
|
||||
shoutOnce(schemeId, "signing with the local " + schemeId + " key FAILED");
|
||||
return List.of();
|
||||
}
|
||||
produced.add(
|
||||
new SchemeSeal(scheme.get().wireId(), validatorIndex, Bytes.wrap(signature.get())));
|
||||
}
|
||||
// Canonical order, so two honest nodes signing the same block emit identical bytes and the
|
||||
// certificate cannot become a source of gratuitous divergence.
|
||||
produced.sort(PqAnchorV2.CANONICAL);
|
||||
return List.copyOf(produced);
|
||||
} catch (final RuntimeException e) {
|
||||
LOG.warn("AERE HIBRID: producer fault at block {}, emitting nothing: {}",
|
||||
blockNumber, e.getMessage());
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
private void shoutOnce(final String schemeId, final String what) {
|
||||
if (alreadyShouted.add(schemeId)) {
|
||||
LOG.error("AERE HIBRID: {} - NO hybrid certificate will be emitted by this node."
|
||||
+ " Fix this BEFORE the enforcement height, not on the day.", what);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,295 +0,0 @@
|
||||
/*
|
||||
* AERE HYBRID, the PRODUCTION loader (2026-08-25). The only place that reads a node's hybrid
|
||||
* configuration and turns it into the two already-proven pieces: HybridSealProducer
|
||||
* (emission) and the schedule+registry pair for PqCommitEnforcement (enforcement).
|
||||
*
|
||||
* THE PROPERTIES (all via BESU_OPTS, like every AERE switch; all absent = today's node,
|
||||
* byte for byte):
|
||||
* aere.pq.schemeSchedule / AERE_PQ_SCHEME_SCHEDULE the schedule "H:scheme+scheme,..."
|
||||
* aere.pq.hybridRegistry / AERE_PQ_HYBRID_REGISTRY path of the hybrid-1 registry
|
||||
* aere.pq.hybrid.attachBlock / AERE_PQ_HYBRID_ATTACHBLOCK height from which extras are EMITTED
|
||||
* aere.pq.hybrid.key.<scheme> / (no env; one path per scheme) the local private key {index, sk}
|
||||
*
|
||||
* EACH CONFIGURATION HALF REFUSES AT STARTUP, with a code and a name:
|
||||
* CONF-03 schedule without registry or the reverse (inherited from enforcement; caught earlier here)
|
||||
* CONF-04 attach armed without schedule+registry: you would emit what nobody can verify
|
||||
* CONF-05 the local key does not bind: index outside the registry, scheme unknown to the
|
||||
* schedule, index different from the local Falcon index, or the probe signature does
|
||||
* not verify against the public key the registry holds (the loader's positive
|
||||
* control: a key that fails its own probe must not boot a node that believes itself armed)
|
||||
*
|
||||
* A mistyped comma does NOT silently boot the node disarmed: the anchor loader's lesson.
|
||||
*/
|
||||
package org.hyperledger.besu.consensus.common.bft;
|
||||
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Properties;
|
||||
|
||||
import org.apache.tuweni.bytes.Bytes;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/** Loads a node's hybrid-seal configuration once and hands out the proven parts. */
|
||||
public final class HybridSealSupport {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(HybridSealSupport.class);
|
||||
|
||||
/** System property naming the scheme schedule. */
|
||||
public static final String PROPERTY_SCHEDULE = "aere.pq.schemeSchedule";
|
||||
/** Environment fallback for {@link #PROPERTY_SCHEDULE}. */
|
||||
public static final String ENV_SCHEDULE = "AERE_PQ_SCHEME_SCHEDULE";
|
||||
/** System property naming the hybrid registry file path. */
|
||||
public static final String PROPERTY_REGISTRY = "aere.pq.hybridRegistry";
|
||||
/** Environment fallback for {@link #PROPERTY_REGISTRY}. */
|
||||
public static final String ENV_REGISTRY = "AERE_PQ_HYBRID_REGISTRY";
|
||||
/** System property naming the emission gate height. */
|
||||
public static final String PROPERTY_ATTACH_BLOCK = "aere.pq.hybrid.attachBlock";
|
||||
/** Environment fallback for {@link #PROPERTY_ATTACH_BLOCK}. */
|
||||
public static final String ENV_ATTACH_BLOCK = "AERE_PQ_HYBRID_ATTACHBLOCK";
|
||||
/** Prefix of the per-scheme local private key path property. */
|
||||
public static final String PROPERTY_KEY_PREFIX = "aere.pq.hybrid.key.";
|
||||
|
||||
/** How this class reaches names and files; swappable so the loader itself is provable. */
|
||||
public interface ConfigReader {
|
||||
/** Returns the raw system property.
|
||||
*
|
||||
* @param name the system property name
|
||||
* @return the value, or null when absent */
|
||||
String property(String name);
|
||||
/** Returns the raw environment variable.
|
||||
*
|
||||
* @param name the environment variable name
|
||||
* @return the value, or null when absent */
|
||||
String environment(String name);
|
||||
/** Returns the file's bytes.
|
||||
*
|
||||
* @param path the file path
|
||||
* @return the bytes
|
||||
* @throws IOException when unreadable */
|
||||
byte[] file(String path) throws IOException;
|
||||
}
|
||||
|
||||
private static final ConfigReader REAL =
|
||||
new ConfigReader() {
|
||||
@Override
|
||||
public String property(final String name) {
|
||||
return System.getProperty(name);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String environment(final String name) {
|
||||
return System.getenv(name);
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] file(final String path) throws IOException {
|
||||
try (InputStream in = new FileInputStream(path)) {
|
||||
return in.readAllBytes();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private static volatile HybridSealSupport instance;
|
||||
|
||||
private final PqSchemeSchedule schedule; // null = not configured
|
||||
private final HybridSignerRegistry registry; // paired with the schedule, never alone
|
||||
private final HybridSealProducer producer; // never null; disarmed when there is nothing
|
||||
|
||||
private HybridSealSupport(
|
||||
final PqSchemeSchedule schedule,
|
||||
final HybridSignerRegistry registry,
|
||||
final HybridSealProducer producer) {
|
||||
this.schedule = schedule;
|
||||
this.registry = registry;
|
||||
this.producer = producer;
|
||||
}
|
||||
|
||||
/** The process-wide instance, loaded from real configuration on first use. */
|
||||
public static HybridSealSupport instance() {
|
||||
HybridSealSupport s = instance;
|
||||
if (s == null) {
|
||||
synchronized (HybridSealSupport.class) {
|
||||
s = instance;
|
||||
if (s == null) {
|
||||
s = load(REAL);
|
||||
instance = s;
|
||||
}
|
||||
}
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Drops the cached instance, for tests only. */
|
||||
static void resetForTesting() {
|
||||
instance = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load from a reader. Public so the loader's refusals are provable without global state.
|
||||
*
|
||||
* @param reader the configuration source
|
||||
* @return the loaded support; fully disarmed when nothing is configured
|
||||
*/
|
||||
public static HybridSealSupport load(final ConfigReader reader) {
|
||||
final String rawSchedule = firstOf(reader, PROPERTY_SCHEDULE, ENV_SCHEDULE);
|
||||
final String rawRegistry = firstOf(reader, PROPERTY_REGISTRY, ENV_REGISTRY);
|
||||
final String rawAttach = firstOf(reader, PROPERTY_ATTACH_BLOCK, ENV_ATTACH_BLOCK);
|
||||
|
||||
if ((rawSchedule == null) != (rawRegistry == null)) {
|
||||
throw new IllegalStateException(
|
||||
"AERE-PQC-COMMIT-CONF-03: " + PROPERTY_SCHEDULE + " and " + PROPERTY_REGISTRY
|
||||
+ " are a PAIR; configure both or neither. Half a hybrid configuration must"
|
||||
+ " refuse at startup, never run half-armed in silence.");
|
||||
}
|
||||
if (rawSchedule == null) {
|
||||
if (rawAttach != null) {
|
||||
throw new IllegalStateException(
|
||||
"AERE-PQC-HYBRID-CONF-04: " + PROPERTY_ATTACH_BLOCK + " is set but the schedule and"
|
||||
+ " registry are not: this node would EMIT seals nobody can verify.");
|
||||
}
|
||||
return new HybridSealSupport(null, null, HybridSealProducer.disarmed());
|
||||
}
|
||||
|
||||
final PqSchemeSchedule schedule;
|
||||
try {
|
||||
schedule = PqSchemeSchedule.parse(rawSchedule);
|
||||
} catch (final RuntimeException e) {
|
||||
throw new IllegalStateException(
|
||||
"AERE-PQC-HYBRID-CONF-04: unparseable " + PROPERTY_SCHEDULE + ": " + e.getMessage());
|
||||
}
|
||||
final HybridSignerRegistry registry;
|
||||
try {
|
||||
final Properties p = new Properties();
|
||||
p.load(
|
||||
new java.io.StringReader(
|
||||
new String(reader.file(rawRegistry), StandardCharsets.UTF_8)));
|
||||
registry = HybridSignerRegistry.fromProperties(p, rawRegistry);
|
||||
} catch (final IOException e) {
|
||||
throw new IllegalStateException(
|
||||
"AERE-PQC-HYBRID-CONF-04: cannot read " + PROPERTY_REGISTRY + " '" + rawRegistry
|
||||
+ "': " + e.getMessage());
|
||||
}
|
||||
|
||||
long attachFrom = HybridSealProducer.NEVER;
|
||||
if (rawAttach != null) {
|
||||
try {
|
||||
attachFrom = Long.parseLong(rawAttach.trim());
|
||||
if (attachFrom < 0) {
|
||||
throw new NumberFormatException("negative");
|
||||
}
|
||||
} catch (final NumberFormatException e) {
|
||||
throw new IllegalStateException(
|
||||
"AERE-PQC-HYBRID-CONF-04: " + PROPERTY_ATTACH_BLOCK
|
||||
+ " is set but not a non-negative height: '" + rawAttach + "'");
|
||||
}
|
||||
}
|
||||
|
||||
// Local private keys, one file per non-Falcon scheme the schedule ever names.
|
||||
final Map<String, SealScheme.PrivateHandle> keys = new HashMap<>();
|
||||
Integer boundIndex = null;
|
||||
for (final SealScheme scheme : SealSchemes.all()) {
|
||||
if (scheme.id().equals(SealSchemes.FALCON_512.id())) {
|
||||
continue; // Falcon-ul are incarcatorul lui, neatins
|
||||
}
|
||||
final String keyPath = reader.property(PROPERTY_KEY_PREFIX + scheme.id());
|
||||
if (keyPath == null) {
|
||||
continue;
|
||||
}
|
||||
final int index;
|
||||
final SealScheme.PrivateHandle handle;
|
||||
try {
|
||||
final Properties kp = new Properties();
|
||||
kp.load(
|
||||
new java.io.StringReader(
|
||||
new String(reader.file(keyPath), StandardCharsets.UTF_8)));
|
||||
index = Integer.parseInt(kp.getProperty("index", "").trim());
|
||||
final byte[] sk =
|
||||
Bytes.fromHexStringLenient(kp.getProperty("sk", "").trim()).toArray();
|
||||
handle =
|
||||
scheme
|
||||
.parsePrivateKey(sk)
|
||||
.orElseThrow(
|
||||
() -> new IllegalStateException("bytes do not parse as a private key"));
|
||||
} catch (final IOException | RuntimeException e) {
|
||||
throw new IllegalStateException(
|
||||
"AERE-PQC-HYBRID-CONF-05: cannot load the local " + scheme.id() + " key from '"
|
||||
+ keyPath + "': " + e.getMessage());
|
||||
}
|
||||
// THE LOADER'S POSITIVE CONTROL: the private key must pass its own probe against the
|
||||
// PUBLIC key the registry holds for this index. A key that fails it must not boot a
|
||||
// node that believes itself armed.
|
||||
final Optional<byte[]> pub = registry.publicKey(index, scheme.id());
|
||||
if (pub.isEmpty()) {
|
||||
throw new IllegalStateException(
|
||||
"AERE-PQC-HYBRID-CONF-05: the registry holds no " + scheme.id() + " key for index "
|
||||
+ index + " (from '" + keyPath + "')");
|
||||
}
|
||||
final byte[] probe = ("AERE-HYBRID-KEY-PROBE:" + index).getBytes(StandardCharsets.UTF_8);
|
||||
final Optional<byte[]> sig = scheme.sign(handle, probe);
|
||||
if (sig.isEmpty() || !scheme.verifyRaw(pub.get(), probe, sig.get())) {
|
||||
throw new IllegalStateException(
|
||||
"AERE-PQC-HYBRID-CONF-05: the local " + scheme.id() + " key at index " + index
|
||||
+ " does NOT verify against the registry's public key. Wrong key, wrong index,"
|
||||
+ " or wrong registry; refusing to start half-armed.");
|
||||
}
|
||||
if (boundIndex != null && boundIndex != index) {
|
||||
throw new IllegalStateException(
|
||||
"AERE-PQC-HYBRID-CONF-05: local hybrid keys disagree on the validator index ("
|
||||
+ boundIndex + " vs " + index + "). One node, one identity.");
|
||||
}
|
||||
boundIndex = index;
|
||||
keys.put(scheme.id(), handle);
|
||||
LOG.info(
|
||||
"AERE HIBRID: loaded local {} signing key for validator index {} (probe verified"
|
||||
+ " against the registry)",
|
||||
scheme.id(),
|
||||
index);
|
||||
}
|
||||
|
||||
if (attachFrom != HybridSealProducer.NEVER && keys.isEmpty()) {
|
||||
throw new IllegalStateException(
|
||||
"AERE-PQC-HYBRID-CONF-04: emission is armed from " + attachFrom + " but this node"
|
||||
+ " holds no local hybrid key (" + PROPERTY_KEY_PREFIX + "<scheme> unset)."
|
||||
+ " It would promise a certificate it cannot produce.");
|
||||
}
|
||||
|
||||
final HybridSealProducer producer =
|
||||
keys.isEmpty()
|
||||
? HybridSealProducer.disarmed()
|
||||
: new HybridSealProducer(attachFrom, schedule, boundIndex, keys);
|
||||
return new HybridSealSupport(schedule, registry, producer);
|
||||
}
|
||||
|
||||
private static String firstOf(final ConfigReader r, final String prop, final String env) {
|
||||
final String p = r.property(prop);
|
||||
return p != null ? p : r.environment(env);
|
||||
}
|
||||
|
||||
/** Returns the schedule, when the hybrid pair is configured.
|
||||
*
|
||||
* @return the schedule, or empty */
|
||||
public Optional<PqSchemeSchedule> schedule() {
|
||||
return Optional.ofNullable(schedule);
|
||||
}
|
||||
|
||||
/** Returns the registry, when the hybrid pair is configured.
|
||||
*
|
||||
* @return the registry, or empty */
|
||||
public Optional<HybridSignerRegistry> registry() {
|
||||
return Optional.ofNullable(registry);
|
||||
}
|
||||
|
||||
/** Returns the producer; disarmed (never emits) when nothing is configured.
|
||||
*
|
||||
* @return the producer */
|
||||
public HybridSealProducer producer() {
|
||||
return producer;
|
||||
}
|
||||
}
|
||||
@ -1,286 +0,0 @@
|
||||
/*
|
||||
* AERE crypto-agility, step 3: the hybrid signer registry.
|
||||
*
|
||||
* WHY. The live registry format holds ONE Falcon key per validator index (896-byte h, plus the
|
||||
* 20-byte address). The founder-approved hybrid (2026-08-07, option 3) needs a registry that can
|
||||
* hold a key PER SCHEME per validator, so a certificate can carry Falcon and SLH-DSA seals from
|
||||
* the same validator and each can be checked against its own key.
|
||||
*
|
||||
* FORMAT (properties):
|
||||
* formatVersion=hybrid-1
|
||||
* chainId=<decimal>
|
||||
* count=<decimal>
|
||||
* <i>.addr=<20-byte hex> mandatory for every index 0..count-1
|
||||
* <i>.key.<schemeId>=<hex> at least one per index; schemeId from SealSchemes
|
||||
*
|
||||
* STRICTNESS, learned the expensive way (blocante_armare 2026-08-06: "a mistyped comma boots
|
||||
* the node DISARMED"): every deviation REFUSES the whole registry loudly - unknown scheme suffix,
|
||||
* wrong key length for its scheme, a hole in the index sequence, a count that disagrees, a
|
||||
* missing address, duplicate keys. A registry that loads "partially" is a node that validates
|
||||
* differently from its peers without knowing it.
|
||||
*
|
||||
* NO REAL KEYS. This class never generates anything. Real hybrid validator keys require the
|
||||
* founder-approved ceremony; tests feed it throwaway pairs from SealScheme.generate.
|
||||
*/
|
||||
package org.hyperledger.besu.consensus.common.bft;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.NavigableMap;
|
||||
import java.util.Properties;
|
||||
import java.util.TreeMap;
|
||||
|
||||
import org.apache.tuweni.bytes.Bytes;
|
||||
import org.apache.tuweni.bytes.Bytes32;
|
||||
import org.hyperledger.besu.crypto.Hash;
|
||||
|
||||
/** The scheme-aware validator key registry for the hybrid certificate. Immutable once loaded. */
|
||||
public final class HybridSignerRegistry {
|
||||
|
||||
/** The exact format marker this loader accepts. */
|
||||
public static final String FORMAT_VERSION = "hybrid-1";
|
||||
|
||||
/** Canonical-hash domain. Distinct from AERE-PQ-REGISTRY-1/-2 (the Falcon-only registry hash
|
||||
* family in PqRegistryHash), so a hybrid registry hash can never be mistaken for a v1/v2 one. */
|
||||
public static final String HASH_DOMAIN = "AERE-PQ-HYBRID-REGISTRY-1";
|
||||
|
||||
private final long chainId;
|
||||
// index -> (schemeId -> key bytes); TreeMap so iteration is canonical by index
|
||||
private final NavigableMap<Integer, Map<String, byte[]>> keys;
|
||||
private final Map<Integer, byte[]> addresses;
|
||||
|
||||
private HybridSignerRegistry(
|
||||
final long chainId,
|
||||
final NavigableMap<Integer, Map<String, byte[]>> keys,
|
||||
final Map<Integer, byte[]> addresses) {
|
||||
this.chainId = chainId;
|
||||
this.keys = keys;
|
||||
this.addresses = addresses;
|
||||
}
|
||||
|
||||
/** Load from a properties file on disk. Refuses loudly, never partially. */
|
||||
public static HybridSignerRegistry load(final Path file) throws IOException {
|
||||
final Properties p = new Properties();
|
||||
try (InputStream in = Files.newInputStream(file)) {
|
||||
p.load(in);
|
||||
}
|
||||
return fromProperties(p, file.toString());
|
||||
}
|
||||
|
||||
/** Load from already-parsed properties. {@code source} names the origin for error messages. */
|
||||
public static HybridSignerRegistry fromProperties(final Properties p, final String source) {
|
||||
final String format = p.getProperty("formatVersion");
|
||||
if (!FORMAT_VERSION.equals(format)) {
|
||||
throw new IllegalArgumentException(
|
||||
"AERE PQ HIBRID: " + source + " declares formatVersion=" + format
|
||||
+ ", this loader accepts only " + FORMAT_VERSION);
|
||||
}
|
||||
final long chainId = parseLong(p.getProperty("chainId"), "chainId", source);
|
||||
final int count = (int) parseLong(p.getProperty("count"), "count", source);
|
||||
if (count <= 0 || count > 1024) {
|
||||
throw new IllegalArgumentException(
|
||||
"AERE PQ HIBRID: " + source + " has count=" + count + ", outside (0, 1024]");
|
||||
}
|
||||
|
||||
final NavigableMap<Integer, Map<String, byte[]>> keys = new TreeMap<>();
|
||||
final Map<Integer, byte[]> addresses = new HashMap<>();
|
||||
|
||||
for (final String name : p.stringPropertyNames()) {
|
||||
if (name.equals("formatVersion") || name.equals("chainId") || name.equals("count")) {
|
||||
continue;
|
||||
}
|
||||
final int dot = name.indexOf('.');
|
||||
if (dot <= 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"AERE PQ HIBRID: " + source + " carries unrecognised entry '" + name + "'");
|
||||
}
|
||||
final int index = parseIndex(name.substring(0, dot), name, source);
|
||||
final String rest = name.substring(dot + 1);
|
||||
final byte[] value = decodeHex(p.getProperty(name), name, source);
|
||||
|
||||
if (rest.equals("addr")) {
|
||||
if (value.length != 20) {
|
||||
throw new IllegalArgumentException(
|
||||
"AERE PQ HIBRID: " + source + " entry '" + name + "' is " + value.length
|
||||
+ " bytes, an address must be exactly 20");
|
||||
}
|
||||
if (addresses.put(index, value) != null) {
|
||||
throw new IllegalArgumentException(
|
||||
"AERE PQ HIBRID: " + source + " repeats address for index " + index);
|
||||
}
|
||||
} else if (rest.startsWith("key.")) {
|
||||
final String schemeId = rest.substring("key.".length());
|
||||
final SealScheme scheme =
|
||||
SealSchemes.byId(schemeId)
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new IllegalArgumentException(
|
||||
"AERE PQ HIBRID: " + source + " entry '" + name
|
||||
+ "' names UNKNOWN scheme '" + schemeId
|
||||
+ "' - refusing the whole registry, an unknown scheme must be loud"));
|
||||
if (value.length != scheme.publicKeyLength()) {
|
||||
throw new IllegalArgumentException(
|
||||
"AERE PQ HIBRID: " + source + " entry '" + name + "' is " + value.length
|
||||
+ " bytes, scheme " + schemeId + " keys are exactly "
|
||||
+ scheme.publicKeyLength());
|
||||
}
|
||||
if (scheme.parsePublicKey(value).isEmpty()) {
|
||||
throw new IllegalArgumentException(
|
||||
"AERE PQ HIBRID: " + source + " entry '" + name
|
||||
+ "' does not parse as a " + schemeId + " public key");
|
||||
}
|
||||
final Map<String, byte[]> perScheme = keys.computeIfAbsent(index, i -> new TreeMap<>());
|
||||
if (perScheme.put(schemeId, value) != null) {
|
||||
throw new IllegalArgumentException(
|
||||
"AERE PQ HIBRID: " + source + " repeats key for index " + index
|
||||
+ " scheme " + schemeId);
|
||||
}
|
||||
} else {
|
||||
throw new IllegalArgumentException(
|
||||
"AERE PQ HIBRID: " + source + " carries unrecognised entry '" + name + "'");
|
||||
}
|
||||
}
|
||||
|
||||
// completeness: every index 0..count-1 present, with an address and at least one key
|
||||
for (int i = 0; i < count; i++) {
|
||||
if (!addresses.containsKey(i)) {
|
||||
throw new IllegalArgumentException(
|
||||
"AERE PQ HIBRID: " + source + " is missing " + i + ".addr (count says " + count + ")");
|
||||
}
|
||||
if (!keys.containsKey(i) || keys.get(i).isEmpty()) {
|
||||
throw new IllegalArgumentException(
|
||||
"AERE PQ HIBRID: " + source + " has no key at all for index " + i);
|
||||
}
|
||||
}
|
||||
if (addresses.size() != count || keys.size() != count) {
|
||||
throw new IllegalArgumentException(
|
||||
"AERE PQ HIBRID: " + source + " carries entries beyond count=" + count
|
||||
+ " (addresses " + addresses.size() + ", key rows " + keys.size() + ")");
|
||||
}
|
||||
|
||||
return new HybridSignerRegistry(chainId, keys, addresses);
|
||||
}
|
||||
|
||||
/** The chain this registry binds to. */
|
||||
public long chainId() {
|
||||
return chainId;
|
||||
}
|
||||
|
||||
/** How many validator indices the registry holds. */
|
||||
public int size() {
|
||||
return keys.size();
|
||||
}
|
||||
|
||||
/** The key of {@code index} under {@code schemeId}, if that validator has one. */
|
||||
public Optional<byte[]> publicKey(final int index, final String schemeId) {
|
||||
final Map<String, byte[]> perScheme = keys.get(index);
|
||||
if (perScheme == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.ofNullable(perScheme.get(schemeId)).map(byte[]::clone);
|
||||
}
|
||||
|
||||
/** The 20-byte address bound to {@code index}, or empty. */
|
||||
public Optional<byte[]> address(final int index) {
|
||||
return Optional.ofNullable(addresses.get(index)).map(byte[]::clone);
|
||||
}
|
||||
|
||||
/** How many indices hold a key under {@code schemeId}. The arming gate for a scheme asks this:
|
||||
* arming a K-of-N threshold under a scheme with coverage below K would be a chain stop. */
|
||||
public int coverage(final String schemeId) {
|
||||
return (int) keys.values().stream().filter(m -> m.containsKey(schemeId)).count();
|
||||
}
|
||||
|
||||
/** The canonical hash: domain || chainId || count || per index asc: index, addr, schemeCount,
|
||||
* then per scheme in id order: idLen, idBytes, keyLen, key. Length-prefixed throughout, keccak
|
||||
* over the whole, same discipline as PqRegistryHash. */
|
||||
public Bytes32 canonicalHash() {
|
||||
final java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();
|
||||
writeAll(out, HASH_DOMAIN.getBytes(StandardCharsets.US_ASCII));
|
||||
writeAll(out, uint64be(chainId));
|
||||
writeAll(out, uint32be(keys.size()));
|
||||
for (final Map.Entry<Integer, Map<String, byte[]>> row : keys.entrySet()) {
|
||||
writeAll(out, uint32be(row.getKey()));
|
||||
writeAll(out, addresses.get(row.getKey()));
|
||||
writeAll(out, uint32be(row.getValue().size()));
|
||||
for (final Map.Entry<String, byte[]> k : row.getValue().entrySet()) {
|
||||
final byte[] id = k.getKey().getBytes(StandardCharsets.US_ASCII);
|
||||
writeAll(out, uint32be(id.length));
|
||||
writeAll(out, id);
|
||||
writeAll(out, uint32be(k.getValue().length));
|
||||
writeAll(out, k.getValue());
|
||||
}
|
||||
}
|
||||
return Hash.keccak256(Bytes.wrap(out.toByteArray()));
|
||||
}
|
||||
|
||||
/** The schemes present for {@code index}, in canonical id order. */
|
||||
public List<String> schemesOf(final int index) {
|
||||
final Map<String, byte[]> perScheme = keys.get(index);
|
||||
return perScheme == null ? List.of() : new ArrayList<>(perScheme.keySet());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------- helpers
|
||||
|
||||
private static long parseLong(final String raw, final String field, final String source) {
|
||||
if (raw == null || raw.isBlank()) {
|
||||
throw new IllegalArgumentException("AERE PQ HIBRID: " + source + " is missing " + field);
|
||||
}
|
||||
try {
|
||||
return Long.parseLong(raw.trim());
|
||||
} catch (final NumberFormatException e) {
|
||||
throw new IllegalArgumentException(
|
||||
"AERE PQ HIBRID: " + source + " field " + field + " is not a number: '" + raw + "'");
|
||||
}
|
||||
}
|
||||
|
||||
private static int parseIndex(final String raw, final String entry, final String source) {
|
||||
try {
|
||||
final int i = Integer.parseInt(raw);
|
||||
if (i < 0) {
|
||||
throw new NumberFormatException("negative");
|
||||
}
|
||||
return i;
|
||||
} catch (final NumberFormatException e) {
|
||||
throw new IllegalArgumentException(
|
||||
"AERE PQ HIBRID: " + source + " entry '" + entry + "' has a bad index '" + raw + "'");
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] decodeHex(final String raw, final String entry, final String source) {
|
||||
if (raw == null || raw.isBlank()) {
|
||||
throw new IllegalArgumentException(
|
||||
"AERE PQ HIBRID: " + source + " entry '" + entry + "' is empty");
|
||||
}
|
||||
try {
|
||||
return Bytes.fromHexStringLenient(raw.trim()).toArray();
|
||||
} catch (final RuntimeException e) {
|
||||
throw new IllegalArgumentException(
|
||||
"AERE PQ HIBRID: " + source + " entry '" + entry + "' is not hex: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static void writeAll(final java.io.ByteArrayOutputStream out, final byte[] b) {
|
||||
out.write(b, 0, b.length);
|
||||
}
|
||||
|
||||
private static byte[] uint32be(final long v) {
|
||||
return new byte[] {(byte) (v >>> 24), (byte) (v >>> 16), (byte) (v >>> 8), (byte) v};
|
||||
}
|
||||
|
||||
private static byte[] uint64be(final long v) {
|
||||
final byte[] b = new byte[8];
|
||||
for (int i = 0; i < 8; i++) {
|
||||
b[i] = (byte) (v >>> (8 * (7 - i)));
|
||||
}
|
||||
return b;
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright contributors to Besu / AERE Network.
|
||||
* Copyright contributors to Besu / Aere Network.
|
||||
*
|
||||
* 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
|
||||
@ -98,21 +98,6 @@ public final class PqAnchor {
|
||||
public static final Bytes COMMIT_DOMAIN_BYTES =
|
||||
Bytes.wrap(COMMIT_DOMAIN.getBytes(StandardCharsets.US_ASCII));
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* If a PREPARE seal signed the same bytes as a commit seal, an adversary could take the PREPARE
|
||||
* dat CINSTIT de un validator si sa il lipeasca pe un COMMIT falsificat: semnatura ar verifica,
|
||||
* si chiar regula pusa sa apere commitul ar fi ocolita. Un singur sir schimbat in preimagine face
|
||||
* cele doua semnaturi netransferabile.
|
||||
*/
|
||||
public static final String PREPARE_DOMAIN = "AERE-PQ-PREPARE-1";
|
||||
|
||||
/** The prepare domain label as raw bytes. */
|
||||
public static final Bytes PREPARE_DOMAIN_BYTES =
|
||||
Bytes.wrap(PREPARE_DOMAIN.getBytes(StandardCharsets.US_ASCII));
|
||||
|
||||
/** Orders Falcon seals by their registry index, ascending. */
|
||||
public static final Comparator<FalconSeal> BY_INDEX =
|
||||
Comparator.comparingInt(FalconSeal::getValidatorIndex);
|
||||
@ -231,48 +216,6 @@ public final class PqAnchor {
|
||||
return Hash.keccak256(out.encoded());
|
||||
}
|
||||
|
||||
/**
|
||||
* The message a PREPARE seal signs: M = keccak256(RLP[PREPARE_DOMAIN, chainId, blockNumber,
|
||||
* round, digest]).
|
||||
*
|
||||
* <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
|
||||
* a seal given in one round must not be movable into another. Without the round, a seal from
|
||||
* PREPARE dintr-o runda esuata ar putea fi refolosit ca sa justifice o alta.
|
||||
*
|
||||
* @param chainId the chain id
|
||||
* @param blockNumber the height being prepared
|
||||
* @param round the round number of the prepare
|
||||
* @param digest the block digest the prepare speaks about
|
||||
* @return the 32-byte message to sign
|
||||
*/
|
||||
public static Bytes32 prepareMessage(
|
||||
final long chainId, final long blockNumber, final int round, final Bytes digest) {
|
||||
if (blockNumber < 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"AERE PQ PREPARE: blockNumber must not be negative (got " + blockNumber + ")");
|
||||
}
|
||||
if (round < 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"AERE PQ PREPARE: round must not be negative (got " + round + ")");
|
||||
}
|
||||
if (digest == null || digest.size() != 32) {
|
||||
throw new IllegalArgumentException(
|
||||
"AERE PQ PREPARE: digest must be 32 bytes (got "
|
||||
+ (digest == null ? "null" : digest.size() + " bytes")
|
||||
+ ")");
|
||||
}
|
||||
final BytesValueRLPOutput out = new BytesValueRLPOutput();
|
||||
out.startList();
|
||||
out.writeBytes(PREPARE_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.
|
||||
*
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright contributors to Besu / AERE Network.
|
||||
* Copyright contributors to Besu / Aere Network.
|
||||
*
|
||||
* 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
|
||||
@ -103,18 +103,19 @@ import org.slf4j.LoggerFactory;
|
||||
* here happens before the node has joined the quorum, is visible in {@code systemctl status} in
|
||||
* second zero, and is repaired with one line and one restart, at a pace the operator controls. The
|
||||
* risk that IS real is a bad shared template plus a parallel fleet restart: at quorum 5 of 7 that is
|
||||
* not degradation, it is a dead chain. The net for it is the one already written in the runbook -
|
||||
* restart one at a time, never in parallel - and a preflight that computes its verdict from THIS
|
||||
* code path rather than from a second reading of the same strings.
|
||||
* not degradation, it is a dead chain. The net for it is the operating rule that goes with this
|
||||
* configuration - restart one at a time, never in parallel - and a preflight that computes its
|
||||
* verdict from THIS code path rather than from a second reading of the same strings.
|
||||
*
|
||||
* <p><b>HONEST LIMITATION, stated in code because it is the same defect class as A8.</b> The values
|
||||
* here are read from LOCAL system properties or environment variables, exactly like {@code
|
||||
* aere.falcon.registry} is today. They are NOT yet read from the genesis {@code config.qbft} /
|
||||
* {@code config.transitions.qbft}, and there is NO consensus binding on them: two nodes configured
|
||||
* with different H or different K schedules will disagree about which headers are valid. Wiring
|
||||
* these to genesis, and refusing to start when the Falcon registry does not match the genesis {@code
|
||||
* pqRegistryHash}, is a PRECONDITION of arming and is tracked as the fork-activation and registry
|
||||
* work items. Until that lands, a non-default value here is a laboratory setting, not a deployment.
|
||||
* <p><b>HONEST LIMITATION, stated in code because it is the same defect class as an unbound
|
||||
* registry.</b> The values here are read from LOCAL system properties or environment variables,
|
||||
* exactly like {@code aere.falcon.registry} is today. They are NOT yet read from the genesis {@code
|
||||
* config.qbft} / {@code config.transitions.qbft}, and there is NO consensus binding on them: two
|
||||
* nodes configured with different H or different K schedules will disagree about which headers are
|
||||
* valid. Wiring these to genesis, and refusing to start when the Falcon registry does not match the
|
||||
* genesis {@code pqRegistryHash}, is a PRECONDITION of arming and is tracked as the fork-activation
|
||||
* and registry work items. Until that lands, a non-default value here is a laboratory setting, not
|
||||
* a deployment.
|
||||
*/
|
||||
public final class PqAnchorConfig {
|
||||
|
||||
@ -154,9 +155,10 @@ public final class PqAnchorConfig {
|
||||
* <p>MEASURED 2026-08-07, and this is why the property exists. A Falcon-512 seal is 666 bytes. The
|
||||
* seal counts observed on a live seven-node run with the threshold at 4 were: 42 blocks with 4, 36
|
||||
* with 5, 5 with 6. The rehearsal's median header of 3838 bytes is {@code (3838-525)/666 = 4.97}
|
||||
* seals. Per node per year, at ~165248 blocks/day: one seal 40.2 GB, three 120.5 GB, five 200.9
|
||||
* GB, seven 281.2 GB. The figure carried in our own documents until that day, 45.2 GB/year, is
|
||||
* 1.13 seals: it had been computed for a single seal and was wrong by 4.4x.
|
||||
* seals. Header bytes therefore scale with the seals actually attached: five seals is about 1.7
|
||||
* times what the same chain writes capped at K=3, and about seven times a seal-less header. The
|
||||
* figure used before that day had been computed for a SINGLE seal, so it understated the cost by
|
||||
* about 4.4x.
|
||||
*
|
||||
* <p>Setting this to K therefore removes ~40% of the anchor's disk cost and takes nothing from the
|
||||
* quorum margin, because the margin is decided by the THRESHOLD a verifier requires, not by how
|
||||
@ -188,10 +190,11 @@ public final class PqAnchorConfig {
|
||||
*
|
||||
* and it is a knob, not an accident.
|
||||
*
|
||||
* <p>MEASURED 2026-08-07, at K=3 capped, ~165248 blocks/day, 666 bytes a seal, per node per year:
|
||||
* every block 120.5 GB; every 10th 12.1 GB; every 100th 1.2 GB; every 256th 0.5 GB. Against a
|
||||
* ~523 ms block, an interval of 100 buys a hundredfold saving for a rewritable tail that grows
|
||||
* from about half a second to about fifty-two seconds. Algorand ships the same shape at 1 in 256.
|
||||
* <p>MEASURED 2026-08-07, at K=3 capped, 666 bytes a seal: the certificate cost falls in exact
|
||||
* proportion to the interval, so every 10th block costs a tenth of the every-block figure, every
|
||||
* 100th a hundredth, every 256th about a 250th. Against a ~523 ms block, an interval of 100 buys
|
||||
* that hundredfold saving for a rewritable tail that grows from about half a second to about
|
||||
* fifty-two seconds. Algorand ships the same shape at 1 in 256.
|
||||
*
|
||||
* <p>UNSET MEANS EVERY BLOCK, which is today's design and the strongest setting. As with the seal
|
||||
* cap, a weakening never arrives as a default; it has to be asked for.
|
||||
@ -222,14 +225,14 @@ public final class PqAnchorConfig {
|
||||
|
||||
/**
|
||||
* The stable, greppable code carried by every startup refusal raised while reading this
|
||||
* configuration, in the shape of the A8 registry refusal {@code AERE-PQC-REG-MISMATCH-01}.
|
||||
* configuration, in the shape of the registry-binding refusal {@code AERE-PQC-REG-MISMATCH-01}.
|
||||
*/
|
||||
public static final String REFUSAL_CODE = "AERE-PQC-ANCHOR-CONF-01";
|
||||
|
||||
/**
|
||||
* D-147: the greppable name of the guard that refuses an armed anchor whose schedule never demands
|
||||
* a single signature. Named, and not just a message, so that a check can ask whether the guard
|
||||
* EXISTS rather than whether some prose happens to be present.
|
||||
* MIN-SEALS FLOOR: the greppable name of the guard that refuses an armed anchor whose schedule
|
||||
* never demands a single signature. Named, and not just a message, so that a check can ask whether
|
||||
* the guard EXISTS rather than whether some prose happens to be present.
|
||||
*/
|
||||
public static final String REFUSAL_MIN_SEALS_FLOOR = REFUSAL_CODE + "/minSealsFloor";
|
||||
|
||||
@ -558,12 +561,12 @@ public final class PqAnchorConfig {
|
||||
"AERE PQ ANCHOR: certificate carried every {} block(s) from H={}, not every block. The "
|
||||
+ "hash chain makes each anchor protect everything BELOW it, so what stays rewritable "
|
||||
+ "by an adversary holding every classical validator key is the TAIL since the last "
|
||||
+ "anchor: fork depth <= {} blocks. This is a DELIBERATE weakening bought for disk: "
|
||||
+ "at K=3 capped it is about {} GB per node per year instead of about 120.",
|
||||
+ "anchor: fork depth <= {} blocks. This is a DELIBERATE weakening bought for header "
|
||||
+ "size: the certificate cost falls to roughly 1/{} of the every-block figure.",
|
||||
iv,
|
||||
config.anchorBlock,
|
||||
iv,
|
||||
String.format("%.1f", 120.5 / iv));
|
||||
iv);
|
||||
}
|
||||
if (config.maxSealsCarried().isPresent() && config.everActive()) {
|
||||
LOG.warn(
|
||||
@ -572,7 +575,7 @@ public final class PqAnchorConfig {
|
||||
+ "header cost and takes NOTHING from the quorum margin, which is decided by the "
|
||||
+ "threshold a verifier demands, not by how many seals a proposer volunteers above "
|
||||
+ "it. Measured 2026-08-07: uncapped, a K=3 chain at N=7 carries about five seals, "
|
||||
+ "which is 200.9 GB per node per year; capped at K it is 120.5 GB.",
|
||||
+ "which is about 1.7 times the header bytes it writes capped at K.",
|
||||
config.maxSealsCarried().getAsInt(),
|
||||
config.highestEffectiveMinSeals());
|
||||
}
|
||||
@ -728,8 +731,8 @@ public final class PqAnchorConfig {
|
||||
}
|
||||
|
||||
/**
|
||||
* D-147, THE MIN-SEALS FLOOR. A schedule whose effective K is ZERO at every height leaves the
|
||||
* anchor ARMED as a structure and completely toothless, for ever.
|
||||
* THE MIN-SEALS FLOOR. A schedule whose effective K is ZERO at every height leaves the anchor
|
||||
* ARMED as a structure and completely toothless, for ever.
|
||||
*
|
||||
* <p>WHAT THE CODE ALREADY GUARDED, and why that was not enough. The loader already refuses a
|
||||
* MISSING schedule, in its own words: {@code "the threshold schedule is missing or empty, so K
|
||||
@ -737,12 +740,12 @@ public final class PqAnchorConfig {
|
||||
* exactly what the danger was. But the guard only fired on an ABSENT schedule. A schedule that is
|
||||
* PRESENT and of the shape {@code <H>:0} reaches the same end state and went through unseen.
|
||||
*
|
||||
* <p>WHY THIS IS NOT THEORETICAL: it is the very shape our activation plan recommends, one that
|
||||
* <p>WHY THIS IS NOT THEORETICAL: it is the very shape a staged activation recommends, one that
|
||||
* STARTS at K=0 as a warm-up window and rises to 3 later. If the second half of the line is lost,
|
||||
* to a truncated environment variable or a misplaced quote, what is left is {@code <H>:0}. The
|
||||
* nodes start, every tool comes out green because each of them measures what was ASKED FOR and
|
||||
* the ask is valid, and the threshold stays zero for ever. Not even ancora-prag-efectiv.sh
|
||||
* catches this: it compares what came out against what was asked for, and here both are zero.
|
||||
* the ask is valid, and the threshold stays zero for ever. Not even a tool that compares the
|
||||
* effective threshold against the requested one catches this: here both of them are zero.
|
||||
*
|
||||
* <p>The warm-up window stays perfectly legal: this looks at the HIGHEST K in the whole schedule,
|
||||
* after the emergency ceiling, so {@code H:0,H+165000:3} passes and {@code H:0} on its own does
|
||||
@ -1025,9 +1028,8 @@ public final class PqAnchorConfig {
|
||||
.append(expected)
|
||||
.append('\n')
|
||||
.append(" FIX correct BESU_OPTS on THIS node and restart ONLY this node\n")
|
||||
.append(" WARNING if the same value is on every node: restart one at a time,\n")
|
||||
.append(" never in parallel. The chain stops as soon as more than f\n")
|
||||
.append(" validators are down at once, whatever the set size is today.\n")
|
||||
.append(" WARNING if the same value is on all seven: restart one at a time,\n")
|
||||
.append(" never in parallel. At quorum 5 of 7 you lose the chain.\n")
|
||||
.append(" EMERGENCY ")
|
||||
.append(PROPERTY_DISABLE)
|
||||
.append("=true starts the node with the anchor off and shouts at every block");
|
||||
@ -1230,7 +1232,7 @@ public final class PqAnchorConfig {
|
||||
* and forces A to be reproduced, which needs a Falcon quorum. What an adversary holding every
|
||||
* classical validator key can still rewrite is the TAIL since the last anchor. Therefore
|
||||
* {@code fork depth <= interval}. At ~523 ms a block, an interval of 100 is about 52 seconds of
|
||||
* rewritable tail, against about half a second at interval 1, and it costs a hundredth of the disk.
|
||||
* rewritable tail, against about half a second at interval 1, at a hundredth of the header cost.
|
||||
*
|
||||
* @param interval the interval, or empty for every block
|
||||
* @return a copy of this configuration carrying the interval
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright contributors to Besu / AERE Network.
|
||||
* Copyright contributors to Besu / Aere Network.
|
||||
*
|
||||
* 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
|
||||
@ -28,8 +28,8 @@ package org.hyperledger.besu.consensus.common.bft;
|
||||
* <p>WHEN IT CAN HAPPEN. Only above the activation height, and only once the operator has raised K
|
||||
* past zero: the first stage is required to be K=0, so activation itself can never refuse. In
|
||||
* steady state the usual cause is a node that restarted and has not yet taken part in a commit, at
|
||||
* most one proposer turn. The other cause, f validators withholding Falcon seals, is the measured
|
||||
* A10 exposure and is the reason the validator set must grow to N>=9 before K is raised to
|
||||
* most one proposer turn. The other cause, f validators withholding Falcon seals, is a measured
|
||||
* exposure of its own and is the reason the validator set must grow to N>=9 before K is raised to
|
||||
* quorum, because at N=7, f=2 the margin is exactly zero.
|
||||
*
|
||||
* <p>Callers on the consensus path must catch this and simply not propose. It carries the numbers a
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright contributors to Besu / AERE Network.
|
||||
* Copyright contributors to Besu / Aere Network.
|
||||
*
|
||||
* 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
|
||||
@ -51,8 +51,8 @@ import org.slf4j.LoggerFactory;
|
||||
* remove.
|
||||
*
|
||||
* <p><b>Inert when the anchor is not configured.</b> With no {@code aere.pq.anchorBlock} this method
|
||||
* returns before it looks at the sync mode, so a binary carrying it behaves exactly as today on
|
||||
* chain 2800 as it stands, where the anchor is not configured on any node.
|
||||
* returns before it looks at the sync mode, so a binary carrying it behaves exactly as it did
|
||||
* before on any chain where the anchor is not configured on any node.
|
||||
*/
|
||||
public final class PqAnchorSyncModeGuard {
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright contributors to Besu / AERE Network.
|
||||
* Copyright contributors to Besu / Aere Network.
|
||||
*
|
||||
* 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
|
||||
@ -39,7 +39,7 @@ import org.slf4j.LoggerFactory;
|
||||
* <ul>
|
||||
* <li>{@code quorum(N)} is Besu's own {@link BftHelpers#calculateRequiredValidatorQuorum(int)},
|
||||
* {@code ceil(2N/3)}. At N=7 that is {@code ceil(14/3) = 5}, so the highest configurable
|
||||
* threshold on chain 2800 as it stands is <b>4</b>.
|
||||
* threshold at that set size is <b>4</b>.
|
||||
* <li>{@code K = quorum} is a GUARANTEED HALT, and that is measured, not argued. A proposer
|
||||
* assembles its certificate out of the Falcon seals it heard on Commit messages, and the block
|
||||
* is IMPORTED at the quorum-th Commit; after the import {@code QbftController.consumeMessage}
|
||||
@ -59,9 +59,9 @@ import org.slf4j.LoggerFactory;
|
||||
* </ul>
|
||||
*
|
||||
* <p><b>What is a refusal and what is only a shout.</b> Above {@code quorum - f} the schedule is
|
||||
* still reachable but has no margin against f silent or keyless signers, which is the measured A10
|
||||
* and A13 exposure. That is a deliberate operator choice with a real cost, so it gets a loud WARN and
|
||||
* the node starts. At or above {@code quorum} the schedule is not reachable at all, so it gets a
|
||||
* still reachable but has no margin against f silent or keyless signers, which is a measured
|
||||
* exposure of its own. That is a deliberate operator choice with a real cost, so it gets a loud
|
||||
* WARN and the node starts. At or above {@code quorum} the schedule is not reachable at all, so it gets a
|
||||
* refusal. A guard that refused both would take the emergency ladder away; a guard that shouted for
|
||||
* both would be the log line this class exists to replace.
|
||||
*
|
||||
@ -76,8 +76,8 @@ import org.slf4j.LoggerFactory;
|
||||
* count the validators" is not "the threshold is probably fine".
|
||||
*
|
||||
* <p><b>Inert when the anchor is not armed.</b> With no {@code aere.pq.anchorBlock}, or with the
|
||||
* anchor emergency-disarmed, this method returns before it computes anything, so a binary carrying it
|
||||
* behaves exactly as today on chain 2800, where the anchor is armed on no node.
|
||||
* anchor emergency-disarmed, this method returns before it computes anything, so a binary carrying
|
||||
* it behaves exactly as it did before on any chain where the anchor is armed on no node.
|
||||
*/
|
||||
public final class PqAnchorThresholdGuard {
|
||||
|
||||
@ -91,26 +91,13 @@ public final class PqAnchorThresholdGuard {
|
||||
/**
|
||||
* The highest seal threshold that may be configured for a validator set of this size.
|
||||
*
|
||||
* <p>REVISED 2026-08-20, and the revision is a measurement, not an opinion. Until D-227
|
||||
* (2026-08-14) a proposer could gather at most {@code quorum} seals: the block imported at the
|
||||
* quorum-th Commit and {@code QbftController.consumeMessage} discarded every later Commit, so this
|
||||
* method returned {@code quorum - 1} and the class doc below carries that history. D-227 (the
|
||||
* late-seal salvage, {@code PqLateSealSalvageTest}) extracts the Falcon seal BEFORE the height
|
||||
* gate discards the message, so the cache now accumulates seals from every ALIVE keyed validator.
|
||||
* Measured on mainnet 2800 across 5,400 anchor blocks (2026-08-18..20): certificates carry 8 and 9
|
||||
* seals at N=9, i.e. strictly more than quorum=6, which under the old mechanics was impossible.
|
||||
*
|
||||
* <p>The bound that remains fatal is availability under the tolerated fault budget: with f
|
||||
* validators Byzantine or down, at most {@code N - f} seals can ever exist, so a threshold above
|
||||
* {@code N - f} halts anchors inside the design's own fault model. At N=9 this is 7; at N=7 it is
|
||||
* 5. A threshold at or above the quorum is now a LIVENESS TAX (anchors wait for late seals),
|
||||
* shouted below, not a guaranteed halt.
|
||||
* <p>One below the QBFT quorum. At N=7 this is 4.
|
||||
*
|
||||
* @param validatorCount the number of validators, at least 1
|
||||
* @return the highest configurable threshold K, {@code N - f}
|
||||
* @return the highest configurable threshold K
|
||||
*/
|
||||
public static int maxConfigurableThreshold(final int validatorCount) {
|
||||
return validatorCount - byzantineBudget(validatorCount);
|
||||
return BftHelpers.calculateRequiredValidatorQuorum(validatorCount) - 1;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -163,7 +150,7 @@ public final class PqAnchorThresholdGuard {
|
||||
}
|
||||
|
||||
final int quorum = BftHelpers.calculateRequiredValidatorQuorum(validatorCount);
|
||||
final int maxConfigurable = maxConfigurableThreshold(validatorCount);
|
||||
final int maxConfigurable = quorum - 1;
|
||||
final int f = byzantineBudget(validatorCount);
|
||||
final int noMarginAbove = quorum - f;
|
||||
|
||||
@ -182,7 +169,7 @@ public final class PqAnchorThresholdGuard {
|
||||
highest = effective;
|
||||
highestAt = at;
|
||||
}
|
||||
if (effective > validatorCount - f && fatalHeight < 0L) {
|
||||
if (effective >= quorum && fatalHeight < 0L) {
|
||||
fatalHeight = at;
|
||||
fatalThreshold = effective;
|
||||
}
|
||||
@ -200,18 +187,21 @@ public final class PqAnchorThresholdGuard {
|
||||
+ validatorCount
|
||||
+ " validators this node is starting into is "
|
||||
+ quorum
|
||||
+ " (ceil(2N/3)) with f = "
|
||||
+ f
|
||||
+ ". The highest threshold that may be configured at this set size is "
|
||||
+ " (ceil(2N/3)). The highest threshold that may be configured at this set size is "
|
||||
+ maxConfigurable
|
||||
+ " = N - f. WHAT THIS MEANS (doctrine revised 2026-08-20 for D-227 late-seal "
|
||||
+ "salvage): the seal cache accumulates seals from every ALIVE keyed validator, "
|
||||
+ "measured on mainnet 2800 as 8-9 seals per certificate at N=9 across 5,400 anchors. "
|
||||
+ "But with f validators Byzantine or down - the design's own fault budget - at most "
|
||||
+ "N - f seals can ever exist, so a threshold of "
|
||||
+ ". WHAT THIS MEANS: a proposer builds its certificate out of the Falcon seals it "
|
||||
+ "heard on Commit messages, and the block is imported at the quorum-th Commit; every "
|
||||
+ "Commit arriving after that import is discarded as targeting a height not above the "
|
||||
+ "chain head, so a proposer can gather at most quorum seals at ANY validator-set "
|
||||
+ "size. Measured on an isolated N=4 network with quorum 3: k=3 on every header above "
|
||||
+ "the activation height, never 4, with all four nodes keyed and healthy. A threshold "
|
||||
+ "of "
|
||||
+ fatalThreshold
|
||||
+ " makes anchor blocks unreachable inside the tolerated fault model. That is a halt "
|
||||
+ "bought by configuration, and it starts at height "
|
||||
+ " therefore requires that ALL of the first "
|
||||
+ quorum
|
||||
+ " Commits carry a valid and eligible Falcon seal; one validator without a key among "
|
||||
+ "them, or one seal that does not verify, and no proposer proposes again. That is a "
|
||||
+ "halt, not a degradation, and it starts at height "
|
||||
+ fatalHeight
|
||||
+ ". WHAT TO DO: lower the step to at most "
|
||||
+ maxConfigurable
|
||||
@ -229,26 +219,6 @@ public final class PqAnchorThresholdGuard {
|
||||
+ ".");
|
||||
}
|
||||
|
||||
if (highest >= quorum) {
|
||||
LOG.warn(
|
||||
"AERE PQ ANCHOR: threshold guard PASSED at a QUORUM-OR-ABOVE threshold. K reaches {} at "
|
||||
+ "height {}; quorum for {} validators is {} and N - f is {}. Reachability now rests "
|
||||
+ "on the D-227 late-seal salvage (measured on mainnet: 8-9 seals per certificate), "
|
||||
+ "and the margin under the fault budget is {}: with f={} validators down, anchors "
|
||||
+ "wait until {} of the remaining {} carry valid seals. This is the operator's "
|
||||
+ "explicit choice of a liveness tax for a quorum-grade certificate.",
|
||||
highest,
|
||||
highestAt,
|
||||
validatorCount,
|
||||
quorum,
|
||||
validatorCount - f,
|
||||
(validatorCount - f) - highest,
|
||||
f,
|
||||
highest,
|
||||
validatorCount - f);
|
||||
return;
|
||||
}
|
||||
|
||||
if (highest > noMarginAbove) {
|
||||
LOG.warn(
|
||||
"AERE PQ ANCHOR: threshold guard PASSED but the schedule has NO MARGIN. K reaches {} at "
|
||||
|
||||
@ -1,185 +0,0 @@
|
||||
/*
|
||||
* AERE crypto-agility, step 2: the versioned anchor certificate.
|
||||
*
|
||||
* WHY A NEW FORMAT. The legacy certificate (PqAnchor.writeCertificate, live on chain 2800) is an
|
||||
* RLP list of [index, signature] pairs: it cannot say WHICH mathematics signed, so it can never
|
||||
* carry the founder-approved hybrid (Falcon + SLH-DSA in one certificate, decision of
|
||||
* 2026-08-07). V2 tags every seal with the one-byte scheme id from SealSchemes.
|
||||
*
|
||||
* HOW THE TWO FORMATS CANNOT BE CONFUSED, by construction and proven in tests:
|
||||
* legacy: RLP [ [idx, sig], ... ] - first element is a LIST
|
||||
* v2: RLP [ 0x02, [ [scheme, idx, sig], ... ] ] - first element is a SCALAR byte
|
||||
* A legacy reader entering v2 bytes finds a scalar where it demands a list and fails loudly; this
|
||||
* decoder REFUSES bytes whose first element is a list (that is legacy, not a malformed v2). The
|
||||
* digest uses a NEW domain string, so a v2 digest can never collide with a v1 digest over related
|
||||
* content: domain separation, same discipline as ANCHOR_DOMAIN v1.
|
||||
*
|
||||
* CANONICAL ORDER. Seals are strictly increasing by (validatorIndex, schemeWireId). One validator
|
||||
* may seal with BOTH schemes (that is the hybrid), but the same (validator, scheme) pair can
|
||||
* appear only once, and any deviation from the canonical order is a decode REFUSAL, not a repair:
|
||||
* a certificate with two encodings would have two digests, and a digest that depends on encoder
|
||||
* mood is not a commitment.
|
||||
*/
|
||||
package org.hyperledger.besu.consensus.common.bft;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.tuweni.bytes.Bytes;
|
||||
import org.apache.tuweni.bytes.Bytes32;
|
||||
import org.hyperledger.besu.crypto.Hash;
|
||||
import org.hyperledger.besu.ethereum.rlp.BytesValueRLPInput;
|
||||
import org.hyperledger.besu.ethereum.rlp.BytesValueRLPOutput;
|
||||
import org.hyperledger.besu.ethereum.rlp.RLPInput;
|
||||
|
||||
/** Encoder/decoder and digest for the v2 (scheme-tagged) anchor certificate. */
|
||||
public final class PqAnchorV2 {
|
||||
|
||||
/** The version scalar that opens every v2 certificate. */
|
||||
public static final int VERSION = 2;
|
||||
|
||||
/** Domain for the v2 anchor digest. NEW string: v1 and v2 digests can never collide. */
|
||||
public static final String ANCHOR_DOMAIN_V2 = "AERE-PQ-ANCHOR-2";
|
||||
|
||||
/** The domain bytes written into every v2 digest preimage. */
|
||||
public static final Bytes ANCHOR_DOMAIN_V2_BYTES =
|
||||
Bytes.wrap(ANCHOR_DOMAIN_V2.getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
/** Canonical order: strictly increasing (validatorIndex, schemeWireId). */
|
||||
public static final Comparator<SchemeSeal> CANONICAL =
|
||||
Comparator.comparingInt(SchemeSeal::getValidatorIndex)
|
||||
.thenComparingInt(s -> s.getSchemeWireId() & 0xff);
|
||||
|
||||
/** Hard cap mirroring the legacy store's defence: a certificate is small and bounded. */
|
||||
public static final int MAX_SEALS = 64;
|
||||
|
||||
private PqAnchorV2() {}
|
||||
|
||||
/** Encode a v2 certificate. The input must already be in canonical order with no duplicate
|
||||
* (validator, scheme) pair and only known schemes; anything else throws: an encoder that
|
||||
* silently reorders would let two byte-strings claim the same certificate. */
|
||||
public static Bytes encode(final List<SchemeSeal> seals) {
|
||||
requireCanonical(seals);
|
||||
final BytesValueRLPOutput out = new BytesValueRLPOutput();
|
||||
out.startList();
|
||||
out.writeIntScalar(VERSION);
|
||||
out.writeList(
|
||||
seals,
|
||||
(seal, rlp) -> {
|
||||
rlp.startList();
|
||||
rlp.writeIntScalar(seal.getSchemeWireId() & 0xff);
|
||||
rlp.writeIntScalar(seal.getValidatorIndex());
|
||||
rlp.writeBytes(seal.getSignature());
|
||||
rlp.endList();
|
||||
});
|
||||
out.endList();
|
||||
return out.encoded();
|
||||
}
|
||||
|
||||
/** Decode a v2 certificate. Throws IllegalArgumentException on ANYTHING that is not a
|
||||
* well-formed, canonical, known-scheme v2 certificate - including legacy bytes, which are
|
||||
* named as such in the message so the caller can tell "old format" from "garbage". */
|
||||
public static List<SchemeSeal> decode(final Bytes encoded) {
|
||||
if (encoded == null || encoded.isEmpty()) {
|
||||
throw new IllegalArgumentException("AERE PQ V2: empty certificate bytes");
|
||||
}
|
||||
final RLPInput in = new BytesValueRLPInput(encoded, false);
|
||||
in.enterList();
|
||||
if (in.nextIsList()) {
|
||||
throw new IllegalArgumentException(
|
||||
"AERE PQ V2: first element is a list - this is a LEGACY (v1) certificate, not v2");
|
||||
}
|
||||
final int version = in.readIntScalar();
|
||||
if (version != VERSION) {
|
||||
throw new IllegalArgumentException(
|
||||
"AERE PQ V2: unknown certificate version " + version + " (this build understands 2)");
|
||||
}
|
||||
final List<SchemeSeal> seals = new ArrayList<>();
|
||||
in.enterList();
|
||||
while (!in.isEndOfCurrentList()) {
|
||||
if (seals.size() >= MAX_SEALS) {
|
||||
throw new IllegalArgumentException(
|
||||
"AERE PQ V2: certificate exceeds " + MAX_SEALS + " seals");
|
||||
}
|
||||
in.enterList();
|
||||
final int scheme = in.readIntScalar();
|
||||
final int index = in.readIntScalar();
|
||||
final Bytes signature = in.readBytes();
|
||||
in.leaveList();
|
||||
if (scheme < 0 || scheme > 0xff) {
|
||||
throw new IllegalArgumentException("AERE PQ V2: scheme tag out of byte range: " + scheme);
|
||||
}
|
||||
seals.add(new SchemeSeal((byte) scheme, index, signature));
|
||||
}
|
||||
in.leaveList();
|
||||
in.leaveList();
|
||||
requireCanonical(seals);
|
||||
return seals;
|
||||
}
|
||||
|
||||
/** The v2 anchor digest: same shape as v1 (chainId, parent number, parent hash, certificate)
|
||||
* under the NEW domain, over the CANONICAL encoding. */
|
||||
public static Bytes32 anchorDigestV2(
|
||||
final long chainId,
|
||||
final long parentNumber,
|
||||
final Bytes parentHash,
|
||||
final List<SchemeSeal> seals) {
|
||||
if (parentNumber < 0) {
|
||||
throw new IllegalArgumentException("AERE PQ V2: parentNumber must not be negative");
|
||||
}
|
||||
if (parentHash == null || parentHash.size() != 32) {
|
||||
throw new IllegalArgumentException("AERE PQ V2: parentHash must be 32 bytes");
|
||||
}
|
||||
final BytesValueRLPOutput out = new BytesValueRLPOutput();
|
||||
out.startList();
|
||||
out.writeBytes(ANCHOR_DOMAIN_V2_BYTES);
|
||||
out.writeLongScalar(chainId);
|
||||
out.writeLongScalar(parentNumber);
|
||||
out.writeBytes(parentHash);
|
||||
out.writeBytes(encode(seals));
|
||||
out.endList();
|
||||
return Hash.keccak256(out.encoded());
|
||||
}
|
||||
|
||||
/** How many DISTINCT validators sealed with the given scheme. The hybrid threshold question
|
||||
* ("K of N under scheme X") is asked per scheme, and a validator counts once per scheme no
|
||||
* matter what canonicality allowed. */
|
||||
public static int distinctValidatorsWith(final Collection<SchemeSeal> seals, final byte wireId) {
|
||||
return (int)
|
||||
seals.stream()
|
||||
.filter(s -> s.getSchemeWireId() == wireId)
|
||||
.mapToInt(SchemeSeal::getValidatorIndex)
|
||||
.distinct()
|
||||
.count();
|
||||
}
|
||||
|
||||
private static void requireCanonical(final List<SchemeSeal> seals) {
|
||||
if (seals == null) {
|
||||
throw new IllegalArgumentException("AERE PQ V2: null seal list");
|
||||
}
|
||||
SchemeSeal prev = null;
|
||||
for (final SchemeSeal s : seals) {
|
||||
if (s.getValidatorIndex() < 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"AERE PQ V2: negative validator index " + s.getValidatorIndex());
|
||||
}
|
||||
if (SealSchemes.byWireId(s.getSchemeWireId()).isEmpty()) {
|
||||
throw new IllegalArgumentException(
|
||||
"AERE PQ V2: unknown scheme tag 0x"
|
||||
+ Integer.toHexString(s.getSchemeWireId() & 0xff)
|
||||
+ " - refusing the whole certificate, an unknown scheme must be loud");
|
||||
}
|
||||
if (prev != null && CANONICAL.compare(prev, s) >= 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"AERE PQ V2: seals not in strictly increasing (validator, scheme) order: "
|
||||
+ prev
|
||||
+ " then "
|
||||
+ s);
|
||||
}
|
||||
prev = s;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -34,14 +34,15 @@ import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* AERE D-146: bind each registry row's FALCON PUBLIC KEY to the VALIDATOR ADDRESS it sits next to.
|
||||
* AERE REGISTRY BINDING: bind each registry row's FALCON PUBLIC KEY to the VALIDATOR ADDRESS it
|
||||
* sits next to.
|
||||
*
|
||||
* <h2>The defect, measured on the real code path on 2026-08-06</h2>
|
||||
*
|
||||
* <p>{@link PqRegistryHash} makes the registry a chain-committed object: two nodes cannot hold
|
||||
* different files without one of them refusing to start. It says so itself, in its own class
|
||||
* javadoc: "It says nothing about whether any validator actually holds the private key matching its
|
||||
* registered public key." That sentence is the whole of D-146.
|
||||
* registered public key." That sentence is the whole of the defect this class closes.
|
||||
*
|
||||
* <p>Concretely, the registry is a table from index i to the pair (ECDSA validator address,
|
||||
* Falcon public key). Seal verification uses the KEY at index i; signer eligibility is checked
|
||||
@ -75,7 +76,7 @@ import org.slf4j.LoggerFactory;
|
||||
* truncated key and that somebody holds the matching secret. It does NOT close T3, T4 or T6:
|
||||
* at the key ceremony the registry writer holds every Falcon secret, so it can sign a
|
||||
* possession proof for key 0 sitting under validator 1's address. Anyone who claims a Falcon
|
||||
* proof-of-possession repairs D-146 is wrong, and the probe measures it.
|
||||
* proof-of-possession repairs this binding defect is wrong, and the probe measures it.
|
||||
* <li>CLAIM, an ECDSA signature by the row's own VALIDATOR key. This is the half that cuts. The
|
||||
* registry writer cannot forge it without validator i's consensus key, so a key cannot be
|
||||
* moved under another validator's address, indices cannot be swapped, and a key cannot appear
|
||||
@ -144,7 +145,7 @@ import org.slf4j.LoggerFactory;
|
||||
* <h2>What this does NOT defend against, stated plainly</h2>
|
||||
*
|
||||
* <ul>
|
||||
* <li>NOT the holder of the vault. Every validator ECDSA key lives in one place; whoever has
|
||||
* <li>NOT the holder of the vault. All seven validator ECDSA keys live in one place; whoever has
|
||||
* them signs a perfectly valid claim for any Falcon key they like. This moves the attack from
|
||||
* "whoever can edit a file" to "whoever holds the consensus keys". The answer to "how many
|
||||
* independent people must agree to stop this chain" is unchanged, and is one.
|
||||
@ -299,7 +300,7 @@ public final class PqRegistryBinding {
|
||||
verifier.init(false, pub);
|
||||
return verifier.verifySignature(digest.toArray(), signature);
|
||||
} catch (final RuntimeException e) {
|
||||
LOG.debug("AERE PQC D-146: Falcon possession verify threw: {}", e.toString());
|
||||
LOG.debug("AERE PQC REGISTRY-BINDING: Falcon possession verify threw: {}", e.toString());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@ -320,7 +321,7 @@ public final class PqRegistryBinding {
|
||||
SignatureAlgorithmFactory.getInstance().decodeSignature(Bytes.wrap(signature));
|
||||
return Util.signatureToAddress(s, Hash.wrap(digest));
|
||||
} catch (final RuntimeException e) {
|
||||
LOG.debug("AERE PQC D-146: claim recovery threw: {}", e.toString());
|
||||
LOG.debug("AERE PQC REGISTRY-BINDING: claim recovery threw: {}", e.toString());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -350,7 +351,7 @@ public final class PqRegistryBinding {
|
||||
if (!registry.addressBound()) {
|
||||
throw new PqRegistryHash.RegistryConfigException(
|
||||
"AERE-PQC-REG-BIND-06",
|
||||
"AERE PQC D-146: registry '"
|
||||
"AERE PQC REGISTRY-BINDING: registry '"
|
||||
+ source
|
||||
+ "' carries binding proofs but is NOT address-bound. A proof binds a Falcon key to a "
|
||||
+ "validator ADDRESS; with no addresses there is nothing to bind to. Refusing "
|
||||
@ -370,7 +371,7 @@ public final class PqRegistryBinding {
|
||||
if (pop == null || pop.length == 0) {
|
||||
throw new PqRegistryHash.RegistryConfigException(
|
||||
"AERE-PQC-REG-BIND-01",
|
||||
"AERE PQC D-146: registry '"
|
||||
"AERE PQC REGISTRY-BINDING: registry '"
|
||||
+ source
|
||||
+ "' index "
|
||||
+ e.index()
|
||||
@ -381,7 +382,7 @@ public final class PqRegistryBinding {
|
||||
if (claim == null || claim.length == 0) {
|
||||
throw new PqRegistryHash.RegistryConfigException(
|
||||
"AERE-PQC-REG-BIND-02",
|
||||
"AERE PQC D-146: registry '"
|
||||
"AERE PQC REGISTRY-BINDING: registry '"
|
||||
+ source
|
||||
+ "' index "
|
||||
+ e.index()
|
||||
@ -394,7 +395,7 @@ public final class PqRegistryBinding {
|
||||
if (!verifyPossession(pk, popDigest, pop)) {
|
||||
throw new PqRegistryHash.RegistryConfigException(
|
||||
"AERE-PQC-REG-BIND-03",
|
||||
"AERE PQC D-146: registry '"
|
||||
"AERE PQC REGISTRY-BINDING: registry '"
|
||||
+ source
|
||||
+ "' index "
|
||||
+ e.index()
|
||||
@ -416,7 +417,7 @@ public final class PqRegistryBinding {
|
||||
if (recovered == null) {
|
||||
throw new PqRegistryHash.RegistryConfigException(
|
||||
"AERE-PQC-REG-BIND-05",
|
||||
"AERE PQC D-146: registry '"
|
||||
"AERE PQC REGISTRY-BINDING: registry '"
|
||||
+ source
|
||||
+ "' index "
|
||||
+ e.index()
|
||||
@ -430,7 +431,7 @@ public final class PqRegistryBinding {
|
||||
if (!recovered.equals(bound)) {
|
||||
throw new PqRegistryHash.RegistryConfigException(
|
||||
"AERE-PQC-REG-BIND-04",
|
||||
"AERE PQC D-146: registry '"
|
||||
"AERE PQC REGISTRY-BINDING: registry '"
|
||||
+ source
|
||||
+ "' index "
|
||||
+ e.index()
|
||||
@ -438,7 +439,7 @@ public final class PqRegistryBinding {
|
||||
+ bound
|
||||
+ " but its claim was signed by "
|
||||
+ recovered
|
||||
+ ". THIS IS THE DEFECT D-146 EXISTS FOR: the Falcon key on this row was filed "
|
||||
+ ". THIS IS THE DEFECT THIS CHECK EXISTS FOR: the Falcon key on this row was filed "
|
||||
+ "under an address whose owner did not sign for it, so every seal made with that "
|
||||
+ "key would be credited to the wrong validator - and if the same key sits at two "
|
||||
+ "indices, a single key holder alone satisfies the quorum threshold. Refusing "
|
||||
@ -449,8 +450,8 @@ public final class PqRegistryBinding {
|
||||
}
|
||||
}
|
||||
LOG.info(
|
||||
"AERE PQC D-146: registry '{}' - all {} rows carry a verified Falcon possession proof and a "
|
||||
+ "verified validator claim (chainId={}, bindHeight={}).",
|
||||
"AERE PQC REGISTRY-BINDING: registry '{}' - all {} rows carry a verified Falcon possession "
|
||||
+ "proof and a verified validator claim (chainId={}, bindHeight={}).",
|
||||
source,
|
||||
count,
|
||||
chainId,
|
||||
|
||||
@ -34,7 +34,7 @@ import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* AERE A8: bind the Falcon validator-index-to-public-key REGISTRY to consensus.
|
||||
* AERE GENESIS BINDING: bind the Falcon validator-index-to-public-key REGISTRY to consensus.
|
||||
*
|
||||
* <p>THE DEFECT THIS EXISTS TO CLOSE. Measured by reading {@code FalconSealSupport}: the registry
|
||||
* that answers "which Falcon public key is validator index i" can be loaded from a plain local
|
||||
@ -81,7 +81,7 @@ public final class PqRegistryHash {
|
||||
public static final String DOMAIN_V1 = "AERE-PQ-REGISTRY-1";
|
||||
|
||||
/**
|
||||
* Domain tag of the canonical v2 pre-image: the same registry PLUS the D-146 binding proofs. A
|
||||
* Domain tag of the canonical v2 pre-image: the same registry PLUS the row binding proofs. A
|
||||
* separate tag, and not a flag inside v1, so that a v1 file and a v2 file can never hash equal and
|
||||
* a downgrade that strips the proofs cannot satisfy a schedule entry that was written for v2.
|
||||
*/
|
||||
@ -126,8 +126,8 @@ public final class PqRegistryHash {
|
||||
private final int index;
|
||||
private final byte[] address; // 20 bytes, or null when the source is not address-bound
|
||||
private final byte[] publicKey;
|
||||
private final byte[] possessionProof; // D-146, or null in a v1 registry
|
||||
private final byte[] claimProof; // D-146, or null in a v1 registry
|
||||
private final byte[] possessionProof; // row binding proof, or null in a v1 registry
|
||||
private final byte[] claimProof; // row binding proof, or null in a v1 registry
|
||||
|
||||
Entry(final int index, final byte[] address, final byte[] publicKey) {
|
||||
this(index, address, publicKey, null, null);
|
||||
@ -174,7 +174,7 @@ public final class PqRegistryHash {
|
||||
}
|
||||
|
||||
/**
|
||||
* D-146. The Falcon signature by this row's own key over the binding pre-image, proving somebody
|
||||
* ROW BINDING. The Falcon signature by this row's own key over the binding pre-image, proving somebody
|
||||
* holds the matching secret.
|
||||
*
|
||||
* @return the possession proof, or null in a v1 registry
|
||||
@ -184,8 +184,9 @@ public final class PqRegistryHash {
|
||||
}
|
||||
|
||||
/**
|
||||
* D-146. The ECDSA signature by this row's own VALIDATOR key over the binding pre-image. This is
|
||||
* the half a registry writer cannot forge, and therefore the half that closes T3 and T6.
|
||||
* ROW BINDING. The ECDSA signature by this row's own VALIDATOR key over the binding pre-image.
|
||||
* This is the half a registry writer cannot forge, and therefore the half that closes both
|
||||
* rebinding a row to another validator's address and swapping two rows.
|
||||
*
|
||||
* @return the claim proof, or null in a v1 registry
|
||||
*/
|
||||
@ -208,9 +209,9 @@ public final class PqRegistryHash {
|
||||
private final String sourcePath;
|
||||
private final List<Entry> entries; // ascending index, contiguous from 0
|
||||
private final boolean addressBound;
|
||||
private final boolean proofBound; // D-146: every row carries both binding proofs
|
||||
private final long declaredChainId; // D-146: the chainId the proofs were signed over
|
||||
private final long bindHeight; // D-146: the activation height the proofs were signed over
|
||||
private final boolean proofBound; // every row carries both binding proofs
|
||||
private final long declaredChainId; // the chainId the proofs were signed over
|
||||
private final long bindHeight; // the activation height the proofs were signed over
|
||||
|
||||
Registry(
|
||||
final SourceKind kind,
|
||||
@ -283,7 +284,7 @@ public final class PqRegistryHash {
|
||||
}
|
||||
|
||||
/**
|
||||
* D-146. Whether every row carries a verified Falcon possession proof and a verified validator
|
||||
* ROW BINDING. Whether every row carries a verified Falcon possession proof and a verified validator
|
||||
* claim. False for every registry written before 2026-08-06.
|
||||
*
|
||||
* @return true iff proof-bound
|
||||
@ -293,7 +294,7 @@ public final class PqRegistryHash {
|
||||
}
|
||||
|
||||
/**
|
||||
* D-146. The chain id the binding proofs were signed over, as the FILE declares it. Cross-checked
|
||||
* ROW BINDING. The chain id the binding proofs were signed over, as the FILE declares it. Cross-checked
|
||||
* against the node's real chain id by {@link #verifyOrAbort}: a registry lifted from the scratch
|
||||
* chain carries proofs that verify perfectly among themselves and belong to another chain.
|
||||
*
|
||||
@ -304,7 +305,7 @@ public final class PqRegistryHash {
|
||||
}
|
||||
|
||||
/**
|
||||
* D-146. The pqRegistryHash schedule height the binding proofs were signed over, so a row
|
||||
* ROW BINDING. The pqRegistryHash schedule height the binding proofs were signed over, so a row
|
||||
* retired at one rotation cannot be replayed into a later registry.
|
||||
*
|
||||
* @return the bind height, or -1 when not proof-bound
|
||||
@ -381,7 +382,7 @@ public final class PqRegistryHash {
|
||||
}
|
||||
|
||||
/**
|
||||
* D-146. The canonical v2 pre-image: everything v1 commits to, plus the activation height and the
|
||||
* ROW BINDING. The canonical v2 pre-image: everything v1 commits to, plus the activation height and the
|
||||
* two binding proofs of every row.
|
||||
*
|
||||
* <pre>
|
||||
@ -406,7 +407,7 @@ public final class PqRegistryHash {
|
||||
* outside it, they would be advisory: a node could be handed the same registry with the proof
|
||||
* fields deleted, it would hash the same, satisfy the schedule, and load without ever verifying
|
||||
* anything. With them inside, stripping a proof is a different registry with a different hash and
|
||||
* the existing A8 guard refuses it. That is also why v2 has its OWN domain tag: a v1 file cannot
|
||||
* the existing genesis-binding guard refuses it. That is also why v2 has its OWN domain tag: a v1 file cannot
|
||||
* collide with a v2 schedule entry, so a format downgrade is refused by machinery that already
|
||||
* exists rather than by a new rule that could be forgotten.
|
||||
*
|
||||
@ -435,7 +436,7 @@ public final class PqRegistryHash {
|
||||
}
|
||||
|
||||
/**
|
||||
* D-146. keccak256 of the canonical v2 pre-image, as 64-hex with no {@code 0x}.
|
||||
* ROW BINDING. keccak256 of the canonical v2 pre-image, as 64-hex with no {@code 0x}.
|
||||
*
|
||||
* @param registry the loaded registry, which must be proof-bound
|
||||
* @param chainId the chain id this registry is bound to
|
||||
@ -446,7 +447,7 @@ public final class PqRegistryHash {
|
||||
}
|
||||
|
||||
/**
|
||||
* D-146. The canonical hash OF THIS REGISTRY: v2 when it carries binding proofs, v1 when it does
|
||||
* ROW BINDING. The canonical hash OF THIS REGISTRY: v2 when it carries binding proofs, v1 when it does
|
||||
* not. Every comparison against a schedule entry goes through here, so a proof-bound registry is
|
||||
* compared as v2 everywhere and a legacy one keeps exactly the number it had before this change.
|
||||
*
|
||||
@ -459,13 +460,13 @@ public final class PqRegistryHash {
|
||||
}
|
||||
|
||||
/**
|
||||
* D-146. The ARMING precondition: refuse to arm the anchor over a registry whose rows are not
|
||||
* ROW BINDING. The ARMING precondition: refuse to arm the anchor over a registry whose rows are not
|
||||
* bound to their validator addresses by signatures.
|
||||
*
|
||||
* <p>The shape is deliberately the same as {@code AERE-PQC-REG-ARM-01}, which already refuses to
|
||||
* arm over a registry with no validator addresses at all, and for the same reason: arming is the
|
||||
* last moment at which the registry format can still be changed. The anchor contract is immutable
|
||||
* once written, so a fleet armed over unbound rows carries D-146 for the life of the chain.
|
||||
* once written, so a fleet armed over unbound rows carries that defect for the life of the chain.
|
||||
*
|
||||
* <p>WIRED 2026-08-06. It is called from {@code
|
||||
* FalconSealSupport.requireRegistryBindingProofsOrAbort()}, in the constructor, immediately after
|
||||
@ -483,7 +484,7 @@ public final class PqRegistryHash {
|
||||
}
|
||||
throw new RegistryConfigException(
|
||||
"AERE-PQC-REG-ARM-02",
|
||||
"AERE PQC D-146: REFUSING TO ARM (fail-closed) over the registry "
|
||||
"AERE PQC ROW-BINDING: REFUSING TO ARM (fail-closed) over the registry "
|
||||
+ (registry == null ? "(none)" : "'" + registry.sourcePath() + "'")
|
||||
+ ", which carries NO binding proofs. Nothing in such a registry connects a Falcon "
|
||||
+ "public key to the validator address on the same row, so whoever writes the file "
|
||||
@ -568,7 +569,7 @@ public final class PqRegistryHash {
|
||||
} catch (final IOException e) {
|
||||
throw new RegistryConfigException(
|
||||
"AERE-PQC-REG-LOAD-01",
|
||||
"AERE PQC A8: cannot read the Falcon registry file '"
|
||||
"AERE PQC GENESIS-BINDING: cannot read the Falcon registry file '"
|
||||
+ path
|
||||
+ "': "
|
||||
+ e
|
||||
@ -581,7 +582,7 @@ public final class PqRegistryHash {
|
||||
if (countRaw == null) {
|
||||
throw new RegistryConfigException(
|
||||
"AERE-PQC-REG-LOAD-02",
|
||||
"AERE PQC A8: the Falcon registry '"
|
||||
"AERE PQC GENESIS-BINDING: the Falcon registry '"
|
||||
+ path
|
||||
+ "' has no 'count' property. Refusing to continue (fail-closed): without a declared "
|
||||
+ "count there is no way to tell a complete registry from one that lost its last "
|
||||
@ -590,7 +591,7 @@ public final class PqRegistryHash {
|
||||
}
|
||||
final int count = parsePositiveInt(countRaw, "count", path.toString());
|
||||
|
||||
// AERE D-146. Header fields of the v2 format. A v1 node reading a v2 file does NOT silently
|
||||
// AERE ROW BINDING. Header fields of the v2 format. A v1 node reading a v2 file does NOT silently
|
||||
// ignore these: parseIndexOrThrow refuses an unrecognised key, so an old binary handed a bound
|
||||
// registry REFUSES rather than loading it with the proofs dropped. That is the correct
|
||||
// direction of failure and it is why the fields are plain top-level names.
|
||||
@ -627,7 +628,7 @@ public final class PqRegistryHash {
|
||||
if (a.length != 20) {
|
||||
throw new RegistryConfigException(
|
||||
"AERE-PQC-REG-LOAD-03",
|
||||
"AERE PQC A8: registry '"
|
||||
"AERE PQC GENESIS-BINDING: registry '"
|
||||
+ path
|
||||
+ "' entry "
|
||||
+ i
|
||||
@ -678,7 +679,7 @@ public final class PqRegistryHash {
|
||||
if (cfg.isMissingNode()) {
|
||||
throw new RegistryConfigException(
|
||||
"AERE-PQC-REG-LOAD-04",
|
||||
"AERE PQC A8: genesis '"
|
||||
"AERE PQC GENESIS-BINDING: genesis '"
|
||||
+ genesisPath
|
||||
+ "' has no config.aereFalconRegistry manifest. Refusing to continue (fail-closed).");
|
||||
}
|
||||
@ -699,7 +700,7 @@ public final class PqRegistryHash {
|
||||
} catch (final IOException e) {
|
||||
throw new RegistryConfigException(
|
||||
"AERE-PQC-REG-LOAD-01",
|
||||
"AERE PQC A8: cannot read '" + path + "': " + e + ". Refusing to continue (fail-closed).");
|
||||
"AERE PQC GENESIS-BINDING: cannot read '" + path + "': " + e + ". Refusing to continue (fail-closed).");
|
||||
}
|
||||
int i = 0;
|
||||
while (i < raw.length && Character.isWhitespace((char) (raw[i] & 0xff))) {
|
||||
@ -720,7 +721,7 @@ public final class PqRegistryHash {
|
||||
if (cfg == null || cfg.isMissingNode() || !cfg.has("count")) {
|
||||
throw new RegistryConfigException(
|
||||
"AERE-PQC-REG-LOAD-05",
|
||||
"AERE PQC A8: manifest '" + source + "' has no 'count'. Refusing (fail-closed).");
|
||||
"AERE PQC GENESIS-BINDING: manifest '" + source + "' has no 'count'. Refusing (fail-closed).");
|
||||
}
|
||||
final int count = parsePositiveInt(cfg.get("count").asText(), "count", source);
|
||||
|
||||
@ -748,7 +749,7 @@ public final class PqRegistryHash {
|
||||
} catch (final NumberFormatException e) {
|
||||
throw new RegistryConfigException(
|
||||
"AERE-PQC-REG-LOAD-18",
|
||||
"AERE PQC A8: manifest '"
|
||||
"AERE PQC GENESIS-BINDING: manifest '"
|
||||
+ source
|
||||
+ "' has the unrecognised field '"
|
||||
+ n
|
||||
@ -761,7 +762,7 @@ public final class PqRegistryHash {
|
||||
if (!strays.isEmpty()) {
|
||||
throw new RegistryConfigException(
|
||||
"AERE-PQC-REG-LOAD-09",
|
||||
"AERE PQC A8: manifest '"
|
||||
"AERE PQC GENESIS-BINDING: manifest '"
|
||||
+ source
|
||||
+ "' declares count="
|
||||
+ count
|
||||
@ -794,7 +795,7 @@ public final class PqRegistryHash {
|
||||
if (e == null) {
|
||||
throw new RegistryConfigException(
|
||||
"AERE-PQC-REG-LOAD-06",
|
||||
"AERE PQC A8: manifest '"
|
||||
"AERE PQC GENESIS-BINDING: manifest '"
|
||||
+ source
|
||||
+ "' declares count="
|
||||
+ count
|
||||
@ -808,7 +809,7 @@ public final class PqRegistryHash {
|
||||
if (a == null || !a.isTextual() || k == null || !k.isTextual()) {
|
||||
throw new RegistryConfigException(
|
||||
"AERE-PQC-REG-LOAD-07",
|
||||
"AERE PQC A8: manifest '"
|
||||
"AERE PQC GENESIS-BINDING: manifest '"
|
||||
+ source
|
||||
+ "' entry "
|
||||
+ i
|
||||
@ -818,7 +819,7 @@ public final class PqRegistryHash {
|
||||
if (addr.length != 20) {
|
||||
throw new RegistryConfigException(
|
||||
"AERE-PQC-REG-LOAD-03",
|
||||
"AERE PQC A8: manifest '"
|
||||
"AERE PQC GENESIS-BINDING: manifest '"
|
||||
+ source
|
||||
+ "' entry "
|
||||
+ i
|
||||
@ -841,7 +842,7 @@ public final class PqRegistryHash {
|
||||
} else {
|
||||
throw new RegistryConfigException(
|
||||
"AERE-PQC-REG-LOAD-08",
|
||||
"AERE PQC A8: manifest '"
|
||||
"AERE PQC GENESIS-BINDING: manifest '"
|
||||
+ source
|
||||
+ "' entry "
|
||||
+ i
|
||||
@ -858,7 +859,7 @@ public final class PqRegistryHash {
|
||||
* -Werror}, so a concrete collection type in a method signature is a build FAILURE, not a style
|
||||
* note. This file had been type-checked standalone with {@code javac -Xlint:all} and reported
|
||||
* clean; that is a weaker statement than it sounds, and the difference is the whole reason the
|
||||
* wiring had to be compiled in the real tree before A8 could be called closed. {@code
|
||||
* wiring had to be compiled in the real tree before this could be called closed. {@code
|
||||
* NavigableMap} keeps the guarantee the code actually relies on, which is ascending key order.
|
||||
*/
|
||||
private static Registry assemble(
|
||||
@ -876,7 +877,7 @@ public final class PqRegistryHash {
|
||||
if (pks.size() != count) {
|
||||
throw new RegistryConfigException(
|
||||
"AERE-PQC-REG-LOAD-09",
|
||||
"AERE PQC A8: registry '"
|
||||
"AERE PQC GENESIS-BINDING: registry '"
|
||||
+ source
|
||||
+ "' declares count="
|
||||
+ count
|
||||
@ -893,7 +894,7 @@ public final class PqRegistryHash {
|
||||
if (!pks.containsKey(i)) {
|
||||
throw new RegistryConfigException(
|
||||
"AERE-PQC-REG-LOAD-10",
|
||||
"AERE PQC A8: registry '"
|
||||
"AERE PQC GENESIS-BINDING: registry '"
|
||||
+ source
|
||||
+ "' is missing index "
|
||||
+ i
|
||||
@ -906,7 +907,7 @@ public final class PqRegistryHash {
|
||||
if (pks.get(i).length == 0) {
|
||||
throw new RegistryConfigException(
|
||||
"AERE-PQC-REG-LOAD-11",
|
||||
"AERE PQC A8: registry '" + source + "' index " + i + " has an EMPTY public key. "
|
||||
"AERE PQC GENESIS-BINDING: registry '" + source + "' index " + i + " has an EMPTY public key. "
|
||||
+ "Refusing (fail-closed).");
|
||||
}
|
||||
}
|
||||
@ -919,7 +920,7 @@ public final class PqRegistryHash {
|
||||
if (!addrs.containsKey(i)) {
|
||||
throw new RegistryConfigException(
|
||||
"AERE-PQC-REG-LOAD-12",
|
||||
"AERE PQC A8: registry '"
|
||||
"AERE PQC GENESIS-BINDING: registry '"
|
||||
+ source
|
||||
+ "' binds addresses for "
|
||||
+ addrs.keySet()
|
||||
@ -931,7 +932,7 @@ public final class PqRegistryHash {
|
||||
} else {
|
||||
throw new RegistryConfigException(
|
||||
"AERE-PQC-REG-LOAD-12",
|
||||
"AERE PQC A8: registry '"
|
||||
"AERE PQC GENESIS-BINDING: registry '"
|
||||
+ source
|
||||
+ "' is MIXED: "
|
||||
+ addrs.size()
|
||||
@ -942,7 +943,7 @@ public final class PqRegistryHash {
|
||||
}
|
||||
|
||||
// ===============================================================================
|
||||
// AERE D-146 (2026-08-06). UNIQUENESS. Nothing here needs a signature, and it is the half that
|
||||
// AERE ROW BINDING (2026-08-06). UNIQUENESS. Nothing here needs a signature, and it is the half that
|
||||
// makes the THRESHOLD real again.
|
||||
//
|
||||
// MEASURED on the real verification path: a registry carrying ONE public key at TWO indices was
|
||||
@ -964,7 +965,7 @@ public final class PqRegistryHash {
|
||||
if (first != null) {
|
||||
throw new RegistryConfigException(
|
||||
"AERE-PQC-REG-LOAD-19",
|
||||
"AERE PQC D-146: registry '"
|
||||
"AERE PQC ROW-BINDING: registry '"
|
||||
+ source
|
||||
+ "' carries the SAME Falcon public key at index "
|
||||
+ first
|
||||
@ -986,7 +987,7 @@ public final class PqRegistryHash {
|
||||
if (first != null) {
|
||||
throw new RegistryConfigException(
|
||||
"AERE-PQC-REG-LOAD-20",
|
||||
"AERE PQC D-146: registry '"
|
||||
"AERE PQC ROW-BINDING: registry '"
|
||||
+ source
|
||||
+ "' binds the SAME validator address 0x"
|
||||
+ a
|
||||
@ -1001,7 +1002,7 @@ public final class PqRegistryHash {
|
||||
}
|
||||
}
|
||||
|
||||
// AERE D-146. FORMAT COHERENCE. Binding proofs are all-or-nothing, exactly like address binding,
|
||||
// AERE ROW BINDING. FORMAT COHERENCE. Binding proofs are all-or-nothing, exactly like address binding,
|
||||
// and for the same reason: one unproven row counts toward the threshold like a proven one.
|
||||
final boolean anyProof = !pops.isEmpty() || !claims.isEmpty();
|
||||
final boolean proofBound;
|
||||
@ -1009,7 +1010,7 @@ public final class PqRegistryHash {
|
||||
if (pops.size() != count || claims.size() != count) {
|
||||
throw new RegistryConfigException(
|
||||
"AERE-PQC-REG-LOAD-21",
|
||||
"AERE PQC D-146: registry '"
|
||||
"AERE PQC ROW-BINDING: registry '"
|
||||
+ source
|
||||
+ "' declares count="
|
||||
+ count
|
||||
@ -1024,7 +1025,7 @@ public final class PqRegistryHash {
|
||||
if (declaredChainId < 0 || bindHeight < 0) {
|
||||
throw new RegistryConfigException(
|
||||
"AERE-PQC-REG-LOAD-22",
|
||||
"AERE PQC D-146: registry '"
|
||||
"AERE PQC ROW-BINDING: registry '"
|
||||
+ source
|
||||
+ "' carries binding proofs but does not declare both 'chainId' and 'bindHeight'. "
|
||||
+ "Both are inside the signed message, so without them the proofs cannot even be "
|
||||
@ -1046,7 +1047,7 @@ public final class PqRegistryHash {
|
||||
proofBound ? claims.get(i) : null));
|
||||
if (pks.get(i).length != FALCON_512_PK_LENGTH) {
|
||||
LOG.warn(
|
||||
"AERE PQC A8: registry '{}' index {} carries a {}-byte public key; Falcon-512 public "
|
||||
"AERE PQC GENESIS-BINDING: registry '{}' index {} carries a {}-byte public key; Falcon-512 public "
|
||||
+ "keys are {} bytes. The registry is NOT rejected for this (the canonical form is "
|
||||
+ "length-prefixed and handles any length), but it is almost certainly the wrong "
|
||||
+ "file or a truncated copy.",
|
||||
@ -1058,18 +1059,18 @@ public final class PqRegistryHash {
|
||||
}
|
||||
final Registry assembled =
|
||||
new Registry(kind, source, entries, bound, proofBound, declaredChainId, bindHeight);
|
||||
// AERE D-146. THE VERIFICATION ITSELF, on the single path every loader funnels through, so it
|
||||
// AERE ROW BINDING. THE VERIFICATION ITSELF, on the single path every loader funnels through, so it
|
||||
// runs at every restart on every node and not only once at the ceremony. Fail-closed, and O(N)
|
||||
// per process start with zero cost per block.
|
||||
PqRegistryBinding.verifyOrThrow(assembled);
|
||||
if (!proofBound) {
|
||||
LOG.warn(
|
||||
"AERE PQC D-146: registry '{}' ({} rows) carries NO binding proofs. Nothing in it "
|
||||
"AERE PQC ROW-BINDING: registry '{}' ({} rows) carries NO binding proofs. Nothing in it "
|
||||
+ "connects a Falcon public key to the validator address on the same row, so whoever "
|
||||
+ "wrote this file decided who every seal is credited to. Measured 2026-08-06 on the "
|
||||
+ "real verification path: swapping two rows produces an ACCEPTED header with no "
|
||||
+ "duplicate key and no duplicate address. This is D-146 and it is not closed on this "
|
||||
+ "node.",
|
||||
+ "duplicate key and no duplicate address. This is the row-binding defect, and it "
|
||||
+ "is not closed on this node.",
|
||||
source,
|
||||
count);
|
||||
}
|
||||
@ -1205,7 +1206,7 @@ public final class PqRegistryHash {
|
||||
if (node.isEmpty()) {
|
||||
throw new RegistryConfigException(
|
||||
"AERE-PQC-REG-SCHED-01",
|
||||
"AERE PQC A8: "
|
||||
"AERE PQC GENESIS-BINDING: "
|
||||
+ source
|
||||
+ " is an EMPTY array. Refusing to start (fail-closed): an empty schedule is not "
|
||||
+ "the same statement as an absent one, and guessing which was meant is exactly how "
|
||||
@ -1217,7 +1218,7 @@ public final class PqRegistryHash {
|
||||
if (!e.isObject() || !e.has("block") || !e.has("hash")) {
|
||||
throw new RegistryConfigException(
|
||||
"AERE-PQC-REG-SCHED-02",
|
||||
"AERE PQC A8: "
|
||||
"AERE PQC GENESIS-BINDING: "
|
||||
+ source
|
||||
+ " entry "
|
||||
+ e
|
||||
@ -1227,7 +1228,7 @@ public final class PqRegistryHash {
|
||||
if (block < 0) {
|
||||
throw new RegistryConfigException(
|
||||
"AERE-PQC-REG-SCHED-03",
|
||||
"AERE PQC A8: "
|
||||
"AERE PQC GENESIS-BINDING: "
|
||||
+ source
|
||||
+ " has a negative or unparseable block in "
|
||||
+ e
|
||||
@ -1236,7 +1237,7 @@ public final class PqRegistryHash {
|
||||
if (block <= previous) {
|
||||
throw new RegistryConfigException(
|
||||
"AERE-PQC-REG-SCHED-04",
|
||||
"AERE PQC A8: "
|
||||
"AERE PQC GENESIS-BINDING: "
|
||||
+ source
|
||||
+ " blocks are not STRICTLY INCREASING ("
|
||||
+ previous
|
||||
@ -1252,7 +1253,7 @@ public final class PqRegistryHash {
|
||||
} else {
|
||||
throw new RegistryConfigException(
|
||||
"AERE-PQC-REG-SCHED-05",
|
||||
"AERE PQC A8: "
|
||||
"AERE PQC GENESIS-BINDING: "
|
||||
+ source
|
||||
+ " must be a 0x-prefixed 32-byte hash or an array of {block, hash}; found "
|
||||
+ node.getNodeType()
|
||||
@ -1319,11 +1320,11 @@ public final class PqRegistryHash {
|
||||
}
|
||||
|
||||
// ===================================================================================
|
||||
// D-081: the whole scheduled history, not only the entry in force at the head
|
||||
// HEIGHT SCHEDULE: the whole scheduled history, not only the entry in force at the head
|
||||
// ===================================================================================
|
||||
|
||||
/**
|
||||
* D-081. The registries a node holds, indexed by the SCHEDULE ENTRY each one satisfies.
|
||||
* HEIGHT SCHEDULE. The registries a node holds, indexed by the SCHEDULE ENTRY each one satisfies.
|
||||
*
|
||||
* <p>THE DEFECT THIS EXISTS TO CLOSE, measured and not assumed. {@code config.pqRegistryHash} is a
|
||||
* schedule, and a second entry is how a key rotation or a revocation is expressed. Enforcement,
|
||||
@ -1360,7 +1361,7 @@ public final class PqRegistryHash {
|
||||
private final List<Long> entryBlocks; // ascending, one per COVERED schedule entry
|
||||
private final List<Registry> entryRegistries; // parallel to entryBlocks
|
||||
private final List<Long> uncovered; // schedule entry blocks with no matching registry
|
||||
private final List<Misbound> misbound; // D-B: hash matched, signed height did not
|
||||
private final List<Misbound> misbound; // signed height did not match the scheduled one
|
||||
|
||||
RegistrySet(
|
||||
final List<Registry> loaded,
|
||||
@ -1403,7 +1404,7 @@ public final class PqRegistryHash {
|
||||
}
|
||||
|
||||
/**
|
||||
* D-B. The schedule entries a held registry reproduces BY HASH and yet was not signed for.
|
||||
* SIGNED-HEIGHT CHECK. The schedule entries a held registry reproduces BY HASH and yet was not signed for.
|
||||
*
|
||||
* @return an unmodifiable list, empty when every covered entry is coherently signed
|
||||
*/
|
||||
@ -1470,7 +1471,7 @@ public final class PqRegistryHash {
|
||||
}
|
||||
|
||||
/**
|
||||
* D-081. Bind a set of loaded registries to the schedule by canonical hash.
|
||||
* HEIGHT SCHEDULE. Bind a set of loaded registries to the schedule by canonical hash.
|
||||
*
|
||||
* <p>The binding is by HASH and never by order or by file name: a registry covers the entry whose
|
||||
* required hash it reproduces, and nothing else makes it cover anything. An operator therefore
|
||||
@ -1499,8 +1500,8 @@ public final class PqRegistryHash {
|
||||
if (!hashFor(r, chainId).equalsIgnoreCase(e.hash)) {
|
||||
continue;
|
||||
}
|
||||
// AERE D-B (2026-08-06). THE LINK THAT WAS NEVER DRAWN. Both numbers have been in this
|
||||
// lexical scope since D-081 was written and they were never put on the same expression.
|
||||
// AERE SIGNED-HEIGHT CHECK (2026-08-06). THE LINK THAT WAS NEVER DRAWN. Both numbers have been in this
|
||||
// lexical scope since the height schedule was written and they were never put on the same expression.
|
||||
//
|
||||
// bindHeight is the height every row's possession proof and every row's validator claim
|
||||
// were SIGNED OVER (PqRegistryBinding.bindingPreimage). e.block is the height from which
|
||||
@ -1512,9 +1513,9 @@ public final class PqRegistryHash {
|
||||
// The node and the tool disagreed and nothing put them face to face.
|
||||
//
|
||||
// WHAT THAT BUYS AN OPERATOR WHO IS NOT SUPPOSED TO HAVE IT: moving the activation day
|
||||
// costs two fresh signatures per validator if this is checked, and ZERO if it is not.
|
||||
// The validators' agreement on a height is only an agreement if something refuses the
|
||||
// heights they did not sign.
|
||||
// costs 14 fresh signatures if this is checked, and ZERO if it is not. The seven
|
||||
// validators' agreement on a height is only an agreement if something refuses the heights
|
||||
// they did not sign.
|
||||
if (r.proofBound() && r.bindHeight() != e.block) {
|
||||
misbound.add(new Misbound(e.block, e.hash, r.sourcePath(), r.bindHeight()));
|
||||
continue;
|
||||
@ -1533,7 +1534,7 @@ public final class PqRegistryHash {
|
||||
}
|
||||
|
||||
/**
|
||||
* D-B. One schedule entry whose required hash a held registry reproduces, and whose height that
|
||||
* SIGNED-HEIGHT CHECK. One schedule entry whose required hash a held registry reproduces, and whose height that
|
||||
* registry's binding proofs were not signed over.
|
||||
*
|
||||
* @param entryBlock the height genesis puts the registry in force from
|
||||
@ -1545,7 +1546,7 @@ public final class PqRegistryHash {
|
||||
long entryBlock, String entryHash, String registryPath, long signedHeight) {}
|
||||
|
||||
/**
|
||||
* D-081. Load every registry named and bind the result to the schedule.
|
||||
* HEIGHT SCHEDULE. Load every registry named and bind the result to the schedule.
|
||||
*
|
||||
* @param schedule the parsed schedule
|
||||
* @param paths the registry files this node holds
|
||||
@ -1563,7 +1564,7 @@ public final class PqRegistryHash {
|
||||
}
|
||||
|
||||
/**
|
||||
* D-081. Split a comma-separated list of registry paths. Blank elements are dropped; a null or
|
||||
* HEIGHT SCHEDULE. Split a comma-separated list of registry paths. Blank elements are dropped; a null or
|
||||
* blank list gives an empty result rather than a path named "".
|
||||
*
|
||||
* @param raw the configured value, may be null
|
||||
@ -1584,7 +1585,7 @@ public final class PqRegistryHash {
|
||||
}
|
||||
|
||||
/**
|
||||
* D-081. The registry in force at a height: the one bound to the schedule entry active there.
|
||||
* HEIGHT SCHEDULE. The registry in force at a height: the one bound to the schedule entry active there.
|
||||
*
|
||||
* <p>This is the function Falcon verification needs. A certificate in a block at height h was
|
||||
* produced under the key set the chain required at h, so it must be checked against that key set
|
||||
@ -1605,7 +1606,7 @@ public final class PqRegistryHash {
|
||||
}
|
||||
|
||||
/**
|
||||
* D-081. Whether the registries this node holds satisfy the binding active at a height.
|
||||
* HEIGHT SCHEDULE. Whether the registries this node holds satisfy the binding active at a height.
|
||||
*
|
||||
* <p>The hash is RECOMPUTED here rather than trusted from the binding built earlier, so that this
|
||||
* answer is a positive proof about the bytes the node is holding right now and not a restatement
|
||||
@ -1711,9 +1712,9 @@ public final class PqRegistryHash {
|
||||
}
|
||||
|
||||
/**
|
||||
* AERE D-A (2026-08-06). THE SAME GUARD, ASKED OF EVERY REGISTRY THIS NODE HOLDS.
|
||||
* AERE HELD-SET SCOPE (2026-08-06). THE SAME GUARD, ASKED OF EVERY REGISTRY THIS NODE HOLDS.
|
||||
*
|
||||
* <p>WHY THIS OVERLOAD HAD TO EXIST, and it is not tidiness. D-081 gave a node a HISTORY of
|
||||
* <p>WHY THIS OVERLOAD HAD TO EXIST, and it is not tidiness. The height schedule gave a node a HISTORY of
|
||||
* registry files, one per rotation the chain has ever performed, precisely so that a node can
|
||||
* validate blocks produced under a retired key set. The startup guard was never told. It compared
|
||||
* the schedule against ONE registry, the one named by {@code registrySourcePath}, and the history
|
||||
@ -1740,19 +1741,19 @@ public final class PqRegistryHash {
|
||||
final long chainHeadNumber,
|
||||
final long chainId) {
|
||||
|
||||
// AERE D-146. The proofs are signed over a chainId the FILE declares. A registry lifted from
|
||||
// AERE ROW BINDING. The proofs are signed over a chainId the FILE declares. A registry lifted from
|
||||
// the scratch chain 442807 carries proofs that verify perfectly among themselves - they are
|
||||
// internally consistent, just for another chain - and would otherwise pass. Fail-closed here,
|
||||
// where the node's real chain id is known and the file's is not yet trusted.
|
||||
//
|
||||
// AERE D-A (2026-08-06): over EVERY held file, not only the primary. A history file lifted from
|
||||
// AERE HELD-SET SCOPE (2026-08-06): over EVERY held file, not only the primary. A history file lifted from
|
||||
// the scratch chain is exactly as dangerous as a primary one - it is the file that answers for
|
||||
// an interval of history - and before this it was never asked.
|
||||
for (final Registry r : set.loaded()) {
|
||||
if (r.proofBound() && r.declaredChainId() != chainId) {
|
||||
throw new RegistryConfigException(
|
||||
"AERE-PQC-REG-BIND-07",
|
||||
"AERE PQC D-146: registry '"
|
||||
"AERE PQC ROW-BINDING: registry '"
|
||||
+ r.sourcePath()
|
||||
+ "' declares chainId="
|
||||
+ r.declaredChainId()
|
||||
@ -1767,7 +1768,7 @@ public final class PqRegistryHash {
|
||||
|
||||
if (!schedule.enforced()) {
|
||||
LOG.warn(
|
||||
"AERE PQC A8: pqRegistryHash is NOT CONFIGURED ({}), so the Falcon registry is NOT bound "
|
||||
"AERE PQC GENESIS-BINDING: pqRegistryHash is NOT CONFIGURED ({}), so the Falcon registry is NOT bound "
|
||||
+ "to consensus on this node. The registry currently loaded is {} ({}, {} entries, "
|
||||
+ "address-bound={}), canonical hash 0x{}. Two nodes holding DIFFERENT registry files "
|
||||
+ "will disagree about which public key validator index i has, so the same "
|
||||
@ -1778,18 +1779,18 @@ public final class PqRegistryHash {
|
||||
registry == null ? SourceKind.NONE : registry.kind(),
|
||||
registry == null ? 0 : registry.count(),
|
||||
registry != null && registry.addressBound(),
|
||||
// A8, measured on a fleet of seven on 2026-08-06: this printed hashV1 next to the text
|
||||
// "put this in config.pqRegistryHash", while THE GATE compares hashFor, which for a
|
||||
// registry carrying proofs is hashV2. With the printed value put into genesis, all seven
|
||||
// nodes start, all seven report the registry loaded, and THE CHAIN STOPS AT H-1. The
|
||||
// guard shouts NOT CORRECTLY STAGED, so it is not a silent halt, but the operator who
|
||||
// follows the node's own instruction halts the fleet. A wrong instruction is more
|
||||
// dangerous than no instruction at all.
|
||||
// GENESIS BINDING, measured on a fleet of seven on 2026-08-06: this printed hashV1 next
|
||||
// to the text "put this in config.pqRegistryHash", while THE GATE compares hashFor,
|
||||
// which for a registry carrying proofs is hashV2. With the printed value put into
|
||||
// genesis, all seven nodes start, all seven report the registry loaded, and THE CHAIN
|
||||
// STOPS AT H-1. The guard shouts NOT CORRECTLY STAGED, so it is not a silent halt, but
|
||||
// the operator who follows the node's own instruction halts the fleet. A wrong
|
||||
// instruction is more dangerous than no instruction at all.
|
||||
registry == null ? "(no registry)" : hashFor(registry, chainId));
|
||||
return GateState.NOT_ENFORCED_NO_SCHEDULE;
|
||||
}
|
||||
|
||||
// AERE D-B (2026-08-06). THE SILENT DEFERRAL. Placed HERE, below the not-enforced exit above,
|
||||
// AERE SIGNED-HEIGHT CHECK (2026-08-06). THE SILENT DEFERRAL. Placed HERE, below the not-enforced exit above,
|
||||
// and that position is a rule and not a preference: on chain 2800 config.pqRegistryHash does not
|
||||
// exist, schedule.enforced() is false, and the return above is the first executable statement
|
||||
// this guard reaches. Nothing new is ever put above it.
|
||||
@ -1814,7 +1815,7 @@ public final class PqRegistryHash {
|
||||
}
|
||||
throw new RegistryConfigException(
|
||||
"AERE-PQC-REG-BIND-08",
|
||||
"AERE PQC D-B: REFUSING TO START - a registry this node holds is scheduled at a height "
|
||||
"AERE PQC SIGNED-HEIGHT: REFUSING TO START - a registry this node holds is scheduled at a height "
|
||||
+ "its validators never signed for.\n"
|
||||
+ " FIELD: 'bindHeight', inside the registry file, versus 'block' of the matching "
|
||||
+ "entry of config.pqRegistryHash in genesis ("
|
||||
@ -1856,8 +1857,8 @@ public final class PqRegistryHash {
|
||||
// - the operator puts the OLD registry back: the node starts and still cannot pass X.
|
||||
// Measured.
|
||||
// Old registry: starts, cannot advance. New registry: cannot start. There was no third file,
|
||||
// and the only exit measured was to set the emergency bypass on every node, i.e. to switch A8
|
||||
// off across the whole fleet in order to cross a PLANNED rotation.
|
||||
// and the only exit measured was to set the emergency bypass on every node, i.e. to switch
|
||||
// the genesis binding off across the whole fleet in order to cross a PLANNED rotation.
|
||||
//
|
||||
// chainHead + 1 is the question the node can actually act on: the only header it will be
|
||||
// offered next is chainHead + 1, and PqRegistryBindingRule judges that header against the entry
|
||||
@ -1872,13 +1873,13 @@ public final class PqRegistryHash {
|
||||
|
||||
if (active.isEmpty()) {
|
||||
final ScheduleEntry first = schedule.entries.get(0);
|
||||
// AERE D-A: ask the SET, not only the primary. A node staged for the activation may already
|
||||
// AERE HELD-SET SCOPE: ask the SET, not only the primary. A node staged for the activation may already
|
||||
// hold the activation registry as history while still signing under the current one.
|
||||
final Registry staged = set.forEntryBlock(first.block());
|
||||
final String computed = registry == null ? null : hashFor(registry, chainId);
|
||||
if (staged != null) {
|
||||
LOG.info(
|
||||
"AERE PQC A8: registry binding is scheduled to start at block {} and this node's chain "
|
||||
"AERE PQC GENESIS-BINDING: registry binding is scheduled to start at block {} and this node's chain "
|
||||
+ "head is {}, so nothing is enforced yet. The registry already loaded ({}, {} "
|
||||
+ "entries) ALREADY MATCHES the hash required from block {}: 0x{}. This node is "
|
||||
+ "correctly staged for the activation.",
|
||||
@ -1890,7 +1891,7 @@ public final class PqRegistryHash {
|
||||
hashFor(staged, chainId));
|
||||
} else {
|
||||
LOG.error(
|
||||
"AERE PQC A8: registry binding starts at block {} and this node's chain head is {}, so "
|
||||
"AERE PQC GENESIS-BINDING: registry binding starts at block {} and this node's chain head is {}, so "
|
||||
+ "nothing is enforced yet AND THIS NODE IS NOT CORRECTLY STAGED. Required from "
|
||||
+ "block {}: 0x{}. Loaded here: {}. This node will run normally and will then "
|
||||
+ "REFUSE the header at block {} (AERE-PQC-REG-BLOCK-01) and stop there. Install "
|
||||
@ -1914,7 +1915,7 @@ public final class PqRegistryHash {
|
||||
if (set.count() == 0) {
|
||||
throw new RegistryConfigException(
|
||||
"AERE-PQC-REG-MISMATCH-02",
|
||||
"AERE PQC A8: REFUSING TO START.\n"
|
||||
"AERE PQC GENESIS-BINDING: REFUSING TO START.\n"
|
||||
+ " EXPECTED: a Falcon validator registry whose canonical hash is\n"
|
||||
+ " 0x"
|
||||
+ required.hash()
|
||||
@ -1953,14 +1954,14 @@ public final class PqRegistryHash {
|
||||
}
|
||||
|
||||
final String computed = hashFor(registry, chainId);
|
||||
// AERE D-A (2026-08-06): the binding is satisfied by ANY file this node holds for this entry,
|
||||
// AERE HELD-SET SCOPE (2026-08-06): the binding is satisfied by ANY file this node holds for this entry,
|
||||
// not only by the one it signs with. Before this line the answer came from the primary registry
|
||||
// alone, and after the first rotation the primary is by definition NOT the file that answers for
|
||||
// the interval below the rotation.
|
||||
final Registry bound = set.forEntryBlock(required.block());
|
||||
if (bound != null) {
|
||||
LOG.info(
|
||||
"AERE PQC A8: registry binding SATISFIED. Loaded {} ({}, {} entries, address-bound={}); "
|
||||
"AERE PQC GENESIS-BINDING: registry binding SATISFIED. Loaded {} ({}, {} entries, address-bound={}); "
|
||||
+ "canonical hash 0x{} equals the hash required from block {} by genesis "
|
||||
+ "config.pqRegistryHash, read from [{}]. Chain head {}, chainId {}. This node holds "
|
||||
+ "{} registry file(s) in total. {}",
|
||||
@ -1970,7 +1971,7 @@ public final class PqRegistryHash {
|
||||
bound.addressBound(),
|
||||
hashFor(bound, chainId),
|
||||
required.block(),
|
||||
// AERE A8: naming the PROVENANCE of the schedule is not decoration. The whole defect
|
||||
// AERE GENESIS BINDING: naming the PROVENANCE of the schedule is not decoration. The whole defect
|
||||
// class is "a value that came from somewhere nobody checked", so a line that says the
|
||||
// binding is satisfied without saying what it was read from asserts more than it knows.
|
||||
schedule.source(),
|
||||
@ -2000,7 +2001,7 @@ public final class PqRegistryHash {
|
||||
|
||||
throw new RegistryConfigException(
|
||||
"AERE-PQC-REG-MISMATCH-01",
|
||||
"AERE PQC A8: REFUSING TO START - the Falcon validator registry on this node is NOT the one "
|
||||
"AERE PQC GENESIS-BINDING: REFUSING TO START - the Falcon validator registry on this node is NOT the one "
|
||||
+ "this chain requires.\n"
|
||||
+ " EXPECTED hash: 0x"
|
||||
+ required.hash()
|
||||
@ -2052,7 +2053,7 @@ public final class PqRegistryHash {
|
||||
}
|
||||
|
||||
/**
|
||||
* AERE D-A. Every registry file this node holds and which scheduled interval each one answers for.
|
||||
* AERE HELD-SET SCOPE. Every registry file this node holds and which scheduled interval each one answers for.
|
||||
* Without this an operator reading {@code MISMATCH-01} cannot tell "I gave this node one file and
|
||||
* it is the wrong one" from "I gave it four and none covers this height", which are different
|
||||
* mistakes with different fixes.
|
||||
@ -2096,11 +2097,12 @@ public final class PqRegistryHash {
|
||||
b.append(" entries : ").append(registry.count()).append('\n');
|
||||
b.append(" addressBound: ").append(registry.addressBound()).append('\n');
|
||||
b.append(" format: ")
|
||||
.append(registry.proofBound() ? "v2, D-146 binding proofs present" : "v1, NO binding proofs")
|
||||
.append(registry.proofBound() ? "v2, binding proofs present" : "v1, NO binding proofs")
|
||||
.append('\n');
|
||||
// A8: the report printed both v1 and v2 without saying WHICH one goes into genesis, and
|
||||
// whoever took the last value off the screen took v1 and halted the fleet at H-1. The one
|
||||
// that matters is now named explicitly, and it is the very one the gate compares: hashFor.
|
||||
// GENESIS BINDING: the report printed both v1 and v2 without saying WHICH one goes into
|
||||
// genesis, and whoever took the last value off the screen took v1 and halted the fleet at
|
||||
// H-1. The one that matters is now named explicitly, and it is the very one the gate
|
||||
// compares: hashFor.
|
||||
b.append(" >>> FOR config.pqRegistryHash: 0x")
|
||||
.append(hashFor(registry, chainId))
|
||||
.append(" <<< this one, and only this one\n");
|
||||
@ -2145,7 +2147,7 @@ public final class PqRegistryHash {
|
||||
} catch (final IOException e) {
|
||||
throw new RegistryConfigException(
|
||||
"AERE-PQC-REG-LOAD-13",
|
||||
"AERE PQC A8: cannot read or parse JSON at '"
|
||||
"AERE PQC GENESIS-BINDING: cannot read or parse JSON at '"
|
||||
+ path
|
||||
+ "': "
|
||||
+ e
|
||||
@ -2163,7 +2165,7 @@ public final class PqRegistryHash {
|
||||
} catch (final NumberFormatException e) {
|
||||
throw new RegistryConfigException(
|
||||
"AERE-PQC-REG-LOAD-14",
|
||||
"AERE PQC A8: registry '"
|
||||
"AERE PQC GENESIS-BINDING: registry '"
|
||||
+ source
|
||||
+ "' has the unrecognised key '"
|
||||
+ raw
|
||||
@ -2175,7 +2177,7 @@ public final class PqRegistryHash {
|
||||
}
|
||||
|
||||
/**
|
||||
* AERE D-146. Parse an OPTIONAL non-negative header field: absent means -1, present means it must
|
||||
* AERE ROW BINDING. Parse an OPTIONAL non-negative header field: absent means -1, present means it must
|
||||
* be a well formed non-negative number. Absent-or-garbage is never collapsed into a default,
|
||||
* because a default is how a threshold quietly becomes zero.
|
||||
*/
|
||||
@ -2190,7 +2192,7 @@ public final class PqRegistryHash {
|
||||
} catch (final NumberFormatException e) {
|
||||
throw new RegistryConfigException(
|
||||
"AERE-PQC-REG-LOAD-23",
|
||||
"AERE PQC D-146: registry '"
|
||||
"AERE PQC ROW-BINDING: registry '"
|
||||
+ source
|
||||
+ "' has "
|
||||
+ what
|
||||
@ -2201,7 +2203,7 @@ public final class PqRegistryHash {
|
||||
if (v < 0) {
|
||||
throw new RegistryConfigException(
|
||||
"AERE-PQC-REG-LOAD-23",
|
||||
"AERE PQC D-146: registry '"
|
||||
"AERE PQC ROW-BINDING: registry '"
|
||||
+ source
|
||||
+ "' has a negative "
|
||||
+ what
|
||||
@ -2222,7 +2224,7 @@ public final class PqRegistryHash {
|
||||
} catch (final NumberFormatException e) {
|
||||
throw new RegistryConfigException(
|
||||
"AERE-PQC-REG-LOAD-15",
|
||||
"AERE PQC A8: registry '"
|
||||
"AERE PQC GENESIS-BINDING: registry '"
|
||||
+ source
|
||||
+ "' has a malformed "
|
||||
+ what
|
||||
@ -2247,7 +2249,7 @@ public final class PqRegistryHash {
|
||||
if (s.isEmpty() || (s.length() & 1) == 1) {
|
||||
throw new RegistryConfigException(
|
||||
"AERE-PQC-REG-LOAD-16",
|
||||
"AERE PQC A8: registry '"
|
||||
"AERE PQC GENESIS-BINDING: registry '"
|
||||
+ source
|
||||
+ "' "
|
||||
+ what
|
||||
@ -2265,7 +2267,7 @@ public final class PqRegistryHash {
|
||||
if (hi < 0 || lo < 0) {
|
||||
throw new RegistryConfigException(
|
||||
"AERE-PQC-REG-LOAD-17",
|
||||
"AERE PQC A8: registry '"
|
||||
"AERE PQC GENESIS-BINDING: registry '"
|
||||
+ source
|
||||
+ "' "
|
||||
+ what
|
||||
@ -2287,7 +2289,7 @@ public final class PqRegistryHash {
|
||||
if (s.length() != 64) {
|
||||
throw new RegistryConfigException(
|
||||
"AERE-PQC-REG-SCHED-06",
|
||||
"AERE PQC A8: "
|
||||
"AERE PQC GENESIS-BINDING: "
|
||||
+ source
|
||||
+ " carries the hash '"
|
||||
+ raw
|
||||
@ -2299,7 +2301,7 @@ public final class PqRegistryHash {
|
||||
if (Character.digit(s.charAt(i), 16) < 0) {
|
||||
throw new RegistryConfigException(
|
||||
"AERE-PQC-REG-SCHED-07",
|
||||
"AERE PQC A8: " + source + " hash '" + raw + "' is not hexadecimal. Refusing to start.");
|
||||
"AERE PQC GENESIS-BINDING: " + source + " hash '" + raw + "' is not hexadecimal. Refusing to start.");
|
||||
}
|
||||
}
|
||||
return s;
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright contributors to Besu / AERE Network.
|
||||
* Copyright contributors to Besu / Aere Network.
|
||||
*
|
||||
* 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
|
||||
@ -23,8 +23,8 @@ import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* D-145. The tool that writes and checks the two configuration values without which the D2 repair
|
||||
* changes nothing.
|
||||
* REGISTRY SCHEDULE TOOL. The tool that writes and checks the two configuration values without
|
||||
* which the height-indexed registry repair changes nothing.
|
||||
*
|
||||
* <p>WHY IT LIVES IN THE CONSENSUS MODULE AND NOT IN A SCRIPT. The value being computed is a
|
||||
* keccak digest over a domain-separated, length-prefixed, chain-bound pre-image, and the node
|
||||
@ -36,8 +36,8 @@ import java.util.Optional;
|
||||
*
|
||||
* <pre>
|
||||
* java -cp 'besu/lib/*' org.hyperledger.besu.consensus.common.bft.PqRegistryHashTool \
|
||||
* verify --chain-id 2800 --genesis ./genesis-2800.json \
|
||||
* --history ./falcon/registry-epoch-0.properties
|
||||
* verify --chain-id 2800 --genesis <config-dir>/genesis.json \
|
||||
* --history <config-dir>/falcon/registry-epoca-0.properties
|
||||
* </pre>
|
||||
*
|
||||
* <p>THREE VERBS.
|
||||
@ -117,13 +117,13 @@ public final class PqRegistryHashTool {
|
||||
System.out.println("chainId=" + chainId + " (goes INTO the pre-image; 2800 and 442807 give different hashes for the same registry)");
|
||||
for (final Path p : files) {
|
||||
final PqRegistryHash.Registry r = PqRegistryHash.loadAuto(p);
|
||||
// AERE D-C (2026-08-06): hashFor, not hashV1. For a registry that carries binding proofs,
|
||||
// hashV1 is a number NOTHING in the node ever compares against: the guard compares hashFor,
|
||||
// that is hashV2. The same mistake, in the `generate` verb below, writes into genesis a hash
|
||||
// the node will never recognise, and then all seven start and the chain stops at H-1.
|
||||
// On top of that, hashV1 does NOT tell two rotation epochs of the same fleet apart, because the
|
||||
// bind height does not enter the v1 pre-image; so it cannot serve even as an epoch identifier
|
||||
// for diagnostics.
|
||||
// AERE CANONICAL FINGERPRINT (2026-08-06): hashFor, not hashV1. For a registry that carries
|
||||
// binding proofs, hashV1 is a number NOTHING in the node ever compares against: the guard
|
||||
// compares hashFor, that is hashV2. The same mistake, in the `generate` verb below, writes
|
||||
// into genesis a hash the node will never recognise, and then all seven start and the chain
|
||||
// stops at H-1. On top of that, hashV1 does NOT tell two rotation epochs of the same fleet
|
||||
// apart, because the bind height does not enter the v1 pre-image; so it cannot serve even as
|
||||
// an epoch identifier for diagnostics.
|
||||
System.out.println(
|
||||
"0x"
|
||||
+ PqRegistryHash.hashFor(r, chainId)
|
||||
@ -146,8 +146,8 @@ public final class PqRegistryHashTool {
|
||||
final String armingHeightRaw = o.get("arming-height");
|
||||
if (armingHeightRaw == null) {
|
||||
System.out.println("NOT MEASURED: --arming-height <H> is missing. The first entry of the");
|
||||
System.out.println(" schedule must be EXACTLY at aere.pq.anchorBlock; see case D");
|
||||
System.out.println(" of dovezi-d2-2026-08-06.");
|
||||
System.out.println(" schedule must be EXACTLY at aere.pq.anchorBlock: a later");
|
||||
System.out.println(" first entry leaves the arming height with no scheduled registry.");
|
||||
return 2;
|
||||
}
|
||||
final long h = Long.parseLong(armingHeightRaw);
|
||||
@ -180,7 +180,7 @@ public final class PqRegistryHashTool {
|
||||
// The fragment LOOKS fine and CANNOT BE USED: genesis is read with Jackson without
|
||||
// ALLOW_COMMENTS, so a node handed one of those refuses to start with
|
||||
// [AERE-PQC-REG-LOAD-13] "Unexpected character ('/')", and it then refuses EVERY header from
|
||||
// the arming height upwards. Measured 2026-08-06, cases U3/U4 in d2-v2/dovezi/controale/.
|
||||
// the arming height upwards. Measured 2026-08-06, with two controls that reproduce the refusal.
|
||||
// Whatever is explanation is printed outside the JSON, on lines beginning with #.
|
||||
final StringBuilder json = new StringBuilder();
|
||||
json.append(" \"pqRegistryHash\": [\n");
|
||||
@ -190,17 +190,17 @@ public final class PqRegistryHashTool {
|
||||
final long b = inaltimi.get(i);
|
||||
final Path p = epoci.get(b);
|
||||
final PqRegistryHash.Registry reg = PqRegistryHash.loadAuto(p);
|
||||
// AERE D-C (2026-08-06). THIS IS THE DANGEROUS VERB: what is printed here gets pasted into
|
||||
// genesis, and genesis is the document all seven nodes hold identical. hashV1 next to a
|
||||
// registry that carries proofs writes into genesis a number the node's guard (hashFor) never
|
||||
// produces, so all seven nodes start, all report the registry loaded, and the chain stops at
|
||||
// H-1. A wrong indication is more dangerous than a missing one.
|
||||
// AERE CANONICAL FINGERPRINT (2026-08-06). THIS IS THE DANGEROUS VERB: what is printed here
|
||||
// gets pasted into genesis, and genesis is the document all seven nodes hold identical.
|
||||
// hashV1 next to a registry that carries proofs writes into genesis a number the node's guard
|
||||
// (hashFor) never produces, so all seven nodes start, all report the registry loaded, and the
|
||||
// chain stops at H-1. A wrong indication is more dangerous than a missing one.
|
||||
final String hash = PqRegistryHash.hashFor(reg, chainId);
|
||||
// AERE D-B (2026-08-06). The recipe we print has to be the one the NEW code accepts. Since
|
||||
// 2026-08-06 the node refuses to start (AERE-PQC-REG-BIND-08) on a registry forced in at a
|
||||
// height its proofs did not sign. If the tool printed that recipe, it would manufacture
|
||||
// exactly the configuration the node rejects, and it would do so in a file that reaches all
|
||||
// seven at once.
|
||||
// AERE SIGNED-HEIGHT CHECK (2026-08-06). The recipe we print has to be the one the NEW code
|
||||
// accepts. Since 2026-08-06 the node refuses to start (AERE-PQC-REG-BIND-08) on a registry
|
||||
// forced in at a height its proofs did not sign. If the tool printed that recipe, it would
|
||||
// manufacture exactly the configuration the node rejects, and it would do so in a file that
|
||||
// reaches all seven at once.
|
||||
if (reg.proofBound() && reg.bindHeight() != b) {
|
||||
System.out.println(
|
||||
"RED: registry "
|
||||
@ -271,10 +271,10 @@ public final class PqRegistryHashTool {
|
||||
System.out.println(
|
||||
"RED: "
|
||||
+ genesis
|
||||
+ " does not carry config.pqRegistryHash. This is D-145 exactly as it was measured: "
|
||||
+ "the machinery exists, it is complete, and it is OFF. A node armed without a "
|
||||
+ "schedule falls back on TODAY's registry and says ACCEPTED for a header it has "
|
||||
+ "bound to no height (case D of dovezi-d2-2026-08-06).");
|
||||
+ " does not carry config.pqRegistryHash. The height-indexed registry schedule "
|
||||
+ "exists, it is complete, and it is OFF. A node armed without a schedule falls back "
|
||||
+ "on TODAY's registry and says ACCEPTED for a header it has bound to no "
|
||||
+ "height.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
@ -311,9 +311,10 @@ public final class PqRegistryHashTool {
|
||||
}
|
||||
System.out.println("registries held = " + set.count());
|
||||
for (final Path p : history) {
|
||||
// AERE D-C: hashFor. This line sits immediately under the list of scheduled epochs printed
|
||||
// with their hashes; two numbers laid one under the other so that they get compared by eye,
|
||||
// and computed with two different functions, are a comparison that can never match.
|
||||
// AERE CANONICAL FINGERPRINT: hashFor. This line sits immediately under the list of scheduled
|
||||
// epochs printed with their hashes; two numbers laid one under the other so that they get
|
||||
// compared by eye, and computed with two different functions, are a comparison that can never
|
||||
// match.
|
||||
final PqRegistryHash.Registry r = PqRegistryHash.loadAuto(p);
|
||||
System.out.println(
|
||||
" "
|
||||
@ -324,9 +325,9 @@ public final class PqRegistryHashTool {
|
||||
}
|
||||
final String armareRaw = o.get("arming-height");
|
||||
int rc = 0;
|
||||
// AERE D-B: the registries that reproduce the required hash and did NOT sign that height. This
|
||||
// is exactly what the node now refuses to start on; until 2026-08-06 the node started and the
|
||||
// tool said nothing.
|
||||
// AERE SIGNED-HEIGHT CHECK: the registries that reproduce the required hash and did NOT sign
|
||||
// that height. This is exactly what the node now refuses to start on; until 2026-08-06 the node
|
||||
// started and the tool said nothing.
|
||||
for (final PqRegistryHash.Misbound m : set.misbound()) {
|
||||
System.out.println(
|
||||
"RED: "
|
||||
@ -340,11 +341,11 @@ public final class PqRegistryHashTool {
|
||||
}
|
||||
if (armareRaw != null) {
|
||||
final long h = Long.parseLong(armareRaw);
|
||||
final long first = schedule.entries().get(0).block();
|
||||
if (first != h) {
|
||||
final long prima = schedule.entries().get(0).block();
|
||||
if (prima != h) {
|
||||
System.out.println(
|
||||
"RED: the first entry of the schedule is at "
|
||||
+ first
|
||||
+ prima
|
||||
+ ", while aere.pq.anchorBlock is "
|
||||
+ h
|
||||
+ ". They must be EQUAL. If the first entry is higher, the heights between H and it "
|
||||
@ -428,9 +429,9 @@ public final class PqRegistryHashTool {
|
||||
}
|
||||
}
|
||||
|
||||
private static List<Path> files(final Map<String, String> o, final String key) {
|
||||
private static List<Path> files(final Map<String, String> o, final String cheie) {
|
||||
final List<Path> out = new ArrayList<>();
|
||||
for (final String s : list(o.get(key))) {
|
||||
for (final String s : list(o.get(cheie))) {
|
||||
out.add(Path.of(s));
|
||||
}
|
||||
return out;
|
||||
@ -468,7 +469,7 @@ public final class PqRegistryHashTool {
|
||||
}
|
||||
|
||||
private static void utilizare() {
|
||||
System.out.println("D-145. The height-indexed registry schedule: hash, generate, verify.");
|
||||
System.out.println("The height-indexed registry schedule: hash, generate, verify.");
|
||||
System.out.println();
|
||||
System.out.println(" hash --chain-id 2800 --registry <f>[,<f>...]");
|
||||
System.out.println(" generate --chain-id 2800 --arming-height <H> --registry <f>");
|
||||
|
||||
@ -1,139 +0,0 @@
|
||||
/*
|
||||
* AERE crypto-agility, step 5: the height-indexed scheme schedule.
|
||||
*
|
||||
* WHAT IT IS. The same shape as aere.pq.anchorMinSeals ("H:K,H:K,..."), but the value at each
|
||||
* step is a SET of scheme ids: "14000000:falcon-512,15500000:falcon-512+slh-dsa-128s" reads
|
||||
* "from 14,000,000 anchors carry Falcon; from 15,500,000 they carry Falcon AND SLH-DSA".
|
||||
* Changing the mathematics of the chain becomes one property plus keys, never a code edit -
|
||||
* that is the whole point of the abstraction layer.
|
||||
*
|
||||
* THE D-147 LESSON, APPLIED AT THE LOADER. The min-seals schedule once accepted a shape whose
|
||||
* DANGEROUS step was later in the schedule, because validation looked only at the first step.
|
||||
* Here every rule runs over the WHOLE schedule at parse time, and the armability gate
|
||||
* (firstUnsatisfied) walks every step against the registry's per-scheme coverage: arming a
|
||||
* threshold K under a scheme whose coverage is below K is a chain stop, so it must be refused
|
||||
* at configuration time, loudly, before any node boots with it.
|
||||
*
|
||||
* SEMANTICS OF "BEFORE THE FIRST STEP": schemesAt returns the empty set, which callers read as
|
||||
* "the v2 scheme world is not armed here" (the legacy untagged Falcon certificate governs).
|
||||
* Empty is never a default INSIDE the schedule: a step with zero schemes is a parse refusal.
|
||||
*/
|
||||
package org.hyperledger.besu.consensus.common.bft;
|
||||
|
||||
import com.google.common.base.Splitter;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
/** Immutable, validated height-to-scheme-set schedule. */
|
||||
public final class PqSchemeSchedule {
|
||||
|
||||
/** One step: from {@code fromBlock} (inclusive) the anchor carries {@code schemeIds}. */
|
||||
public record Step(long fromBlock, Set<String> schemeIds) {}
|
||||
|
||||
private final List<Step> steps;
|
||||
|
||||
private PqSchemeSchedule(final List<Step> steps) {
|
||||
this.steps = steps;
|
||||
}
|
||||
|
||||
/** Parse "H:scheme[+scheme...],H:...". Refuses the WHOLE schedule on any defect: unknown or
|
||||
* repeated scheme in a step, empty step, non-increasing heights, negative height, garbage. */
|
||||
public static PqSchemeSchedule parse(final String raw) {
|
||||
if (raw == null || raw.isBlank()) {
|
||||
throw new IllegalArgumentException("AERE PQ ORAR-SCHEME: empty schedule");
|
||||
}
|
||||
final List<Step> steps = new ArrayList<>();
|
||||
long lastHeight = -1;
|
||||
for (final String piesa : Splitter.on(',').split(raw)) {
|
||||
final List<String> parti = Splitter.on(':').splitToList(piesa.trim());
|
||||
if (parti.size() != 2) {
|
||||
throw new IllegalArgumentException(
|
||||
"AERE PQ ORAR-SCHEME: step '" + piesa.trim() + "' is not H:schemes");
|
||||
}
|
||||
final long h;
|
||||
try {
|
||||
h = Long.parseLong(parti.get(0).trim());
|
||||
} catch (final NumberFormatException e) {
|
||||
throw new IllegalArgumentException(
|
||||
"AERE PQ ORAR-SCHEME: height '" + parti.get(0).trim() + "' is not a number");
|
||||
}
|
||||
if (h < 0) {
|
||||
throw new IllegalArgumentException("AERE PQ ORAR-SCHEME: negative height " + h);
|
||||
}
|
||||
if (h <= lastHeight) {
|
||||
throw new IllegalArgumentException(
|
||||
"AERE PQ ORAR-SCHEME: heights must strictly increase ("
|
||||
+ lastHeight
|
||||
+ " then "
|
||||
+ h
|
||||
+ ") - a schedule read out of order would arm the wrong mathematics");
|
||||
}
|
||||
lastHeight = h;
|
||||
final Set<String> schemes = new LinkedHashSet<>();
|
||||
for (final String id : Splitter.on('+').split(parti.get(1))) {
|
||||
final String curat = id.trim();
|
||||
if (curat.isEmpty()) {
|
||||
throw new IllegalArgumentException(
|
||||
"AERE PQ ORAR-SCHEME: step at " + h + " carries an empty scheme name");
|
||||
}
|
||||
if (SealSchemes.byId(curat).isEmpty()) {
|
||||
throw new IllegalArgumentException(
|
||||
"AERE PQ ORAR-SCHEME: step at " + h + " names UNKNOWN scheme '" + curat
|
||||
+ "' - refusing the whole schedule");
|
||||
}
|
||||
if (!schemes.add(curat)) {
|
||||
throw new IllegalArgumentException(
|
||||
"AERE PQ ORAR-SCHEME: step at " + h + " repeats scheme '" + curat + "'");
|
||||
}
|
||||
}
|
||||
if (schemes.isEmpty()) {
|
||||
throw new IllegalArgumentException(
|
||||
"AERE PQ ORAR-SCHEME: step at " + h + " has no schemes at all");
|
||||
}
|
||||
steps.add(new Step(h, Set.copyOf(schemes)));
|
||||
}
|
||||
return new PqSchemeSchedule(List.copyOf(steps));
|
||||
}
|
||||
|
||||
/** The scheme set in force at {@code height}: the last step at or below it, or the empty set
|
||||
* when the schedule has not started yet (= v2 not armed, legacy governs). */
|
||||
public Set<String> schemesAt(final long height) {
|
||||
Set<String> inForce = Set.of();
|
||||
for (final Step s : steps) {
|
||||
if (s.fromBlock() <= height) {
|
||||
inForce = s.schemeIds();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return inForce;
|
||||
}
|
||||
|
||||
/** All steps, ascending. */
|
||||
public List<Step> steps() {
|
||||
return steps;
|
||||
}
|
||||
|
||||
/** The armability gate: walk EVERY step and every scheme in it against the registry's
|
||||
* per-scheme coverage; the first (height, scheme) whose coverage is below {@code minSeals}
|
||||
* is returned as the refusal, with numbers. Empty means the whole schedule is armable.
|
||||
* This is the D-147 discipline: the dangerous step may be the LAST one, so all are walked. */
|
||||
public Optional<String> firstUnsatisfied(final HybridSignerRegistry registry, final int minSeals) {
|
||||
for (final Step s : steps) {
|
||||
for (final String scheme : s.schemeIds()) {
|
||||
final int acoperire = registry.coverage(scheme);
|
||||
if (acoperire < minSeals) {
|
||||
return Optional.of(
|
||||
"step at height " + s.fromBlock() + " arms scheme '" + scheme
|
||||
+ "' with required seals " + minSeals + " but the registry covers only "
|
||||
+ acoperire + " validator(s) - arming this would stop the chain");
|
||||
}
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright contributors to Besu / AERE Network.
|
||||
* Copyright contributors to Besu / Aere Network.
|
||||
*
|
||||
* 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
|
||||
@ -52,12 +52,13 @@ import org.slf4j.LoggerFactory;
|
||||
* amount of waiting healed it. The seals over M(head) exist nowhere else: they travel only on the
|
||||
* Commit messages of the head block, and those are never replayed.
|
||||
*
|
||||
* <p>AND IT IS NOT DEFECT A8 IN ANOTHER COAT. A8 was a registry of public keys read from a file and
|
||||
* BELIEVED. Every seal read back here is re-verified, cryptographically, against the anchored
|
||||
* registry over M rebuilt from the header this process just loaded - see {@link PqSealStore}. A
|
||||
* forged file cannot inject a seal without forging a Falcon-512 signature; the worst it achieves is
|
||||
* the empty cache an absent file already gives. Persistence is OFF unless a caller enables it, and
|
||||
* the only caller that does is the QBFT controller builder, only when the anchor is actually armed.
|
||||
* <p>AND IT IS NOT THE UNBOUND-REGISTRY DEFECT IN ANOTHER COAT. That one was a registry of public
|
||||
* keys read from a file and BELIEVED. Every seal read back here is re-verified, cryptographically,
|
||||
* against the anchored registry over M rebuilt from the header this process just loaded - see
|
||||
* {@link PqSealStore}. A forged file cannot inject a seal without forging a Falcon-512 signature;
|
||||
* the worst it achieves is the empty cache an absent file already gives. Persistence is OFF unless
|
||||
* a caller enables it, and the only caller that does is the QBFT controller builder, only when the
|
||||
* anchor is actually armed.
|
||||
*
|
||||
* <p>WHAT IT DOES NOT DO. The in-memory path verifies nothing. Whether a seal is valid, whether its
|
||||
* index maps to an eligible validator, and whether there are enough of them, are decided at
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright contributors to Besu / AERE Network.
|
||||
* Copyright contributors to Besu / Aere Network.
|
||||
*
|
||||
* 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
|
||||
@ -53,10 +53,10 @@ import org.slf4j.LoggerFactory;
|
||||
* and nobody could propose. Seals come from Commits, Commits come from proposals, proposals need
|
||||
* seals - the same circle, one level down.
|
||||
*
|
||||
* <p><b>WHY THIS IS NOT DEFECT A8 IN ANOTHER COAT, and the distinction is the whole safety
|
||||
* argument.</b> A8 was a REGISTRY read from a local file and BELIEVED: the public keys that decide
|
||||
* who is a legitimate signer came out of a file a node could be pointed at wrongly, so the file was
|
||||
* authority. Nothing here is believed. A Falcon seal is SELF-AUTHENTICATING: {@link
|
||||
* <p><b>WHY THIS IS NOT THE UNBOUND-REGISTRY DEFECT IN ANOTHER COAT, and the distinction is the
|
||||
* whole safety argument.</b> That one was a REGISTRY read from a local file and BELIEVED: the
|
||||
* public keys that decide who is a legitimate signer came out of a file a node could be pointed at
|
||||
* wrongly, so the file was authority. Nothing here is believed. A Falcon seal is SELF-AUTHENTICATING: {@link
|
||||
* #readVerified(Path, long, long, Hash, PqSignerRegistry)} re-verifies EVERY seal it reads against
|
||||
* the anchored registry, over the message M rebuilt from the chain-head header this process just
|
||||
* loaded, exactly as the producer does at selection time. A forged, edited or replayed file cannot
|
||||
@ -320,11 +320,11 @@ public final class PqSealStore {
|
||||
if (seal.getValidatorIndex() < 0
|
||||
|| seal.getSignature() == null
|
||||
|| !seen.add(seal.getValidatorIndex())
|
||||
// D2 (2026-08-06): height-resolved. These seals are over block `blockNumber`, which the
|
||||
// HEIGHT-RESOLVED LOOKUP (2026-08-06). These seals are over block `blockNumber`, which the
|
||||
// caller has already matched against the stored header, so the height is known exactly.
|
||||
// On a restart at the head this resolves to the same registry it always did; the point is
|
||||
// that it can no longer resolve to a DIFFERENT one without saying so.
|
||||
// D2 (b-v2): the OWN-HEAD door. The caller has already refused this file unless the
|
||||
// OWN-HEAD DOOR (b-v2): the caller has already refused this file unless the
|
||||
// stored block number and hash equal this node's head, so the subject is this node's
|
||||
// own head by construction.
|
||||
|| registry.addressForIndexAtOwnHead(blockNumber, seal.getValidatorIndex()) == null) {
|
||||
@ -359,7 +359,8 @@ public final class PqSealStore {
|
||||
* makes when it decides which heard seals may enter a certificate. Nothing about a seal is trusted
|
||||
* because it was on disk.
|
||||
*
|
||||
* <p>D2 (2026-08-06): it now carries the HEIGHT the seals belong to. The adversarial review of
|
||||
* <p>HEIGHT-RESOLVED LOOKUP (2026-08-06): it now carries the HEIGHT the seals belong to. The
|
||||
* adversarial review of
|
||||
* 2026-08-02 measured that every registry question in this stack was height-less, so a restart
|
||||
* after a key rotation re-checked seals over an old block against today's keys and dropped them
|
||||
* all as forged. Here the height is not in doubt: the caller has already refused the file unless
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright contributors to Besu / AERE Network.
|
||||
* Copyright contributors to Besu / Aere Network.
|
||||
*
|
||||
* 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
|
||||
@ -37,17 +37,18 @@ import org.apache.tuweni.bytes.Bytes;
|
||||
public interface PqSignerRegistry {
|
||||
|
||||
/**
|
||||
* D-081 / D2 HARDENING (a). The validator address bound to a registry index AT A HEIGHT.
|
||||
* HEIGHT-INDEXED REGISTRY, LOOKUP HARDENING (a). The validator address bound to a registry index
|
||||
* AT A HEIGHT.
|
||||
*
|
||||
* <p>WHY THERE IS NO HEIGHT-LESS FORM HERE, and why this is the change and not a nicety. Until
|
||||
* 2026-08-06 this interface carried BOTH {@code addressForIndex(int)} and a {@code
|
||||
* addressForIndexAt(long,int)} whose body was {@code default { return addressForIndex(idx); }}.
|
||||
* That default is precisely what the adversarial review of 2026-08-02 measured as D2: a registry
|
||||
* That default is precisely the defect an adversarial review of 2026-08-02 measured: a registry
|
||||
* has no height argument, so a header that verified yesterday is refused the moment index 0's
|
||||
* Falcon key is rotated. The default made the defect INVISIBLE TO ITS OWN PROOF - the D2 harness
|
||||
* (adversar-2026-08-02/harness/RuleProbe.java lines 115-131) injects a registry that overrides
|
||||
* only the height-less pair, inherits the default, and therefore returns exactly the same verdict
|
||||
* on repaired and unrepaired code. A probe that cannot go red is not a probe.
|
||||
* Falcon key is rotated. The default made the defect INVISIBLE TO ITS OWN PROOF - that review's
|
||||
* probe injected a registry that overrides only the height-less pair, inherits the default, and
|
||||
* therefore returns exactly the same verdict on repaired and unrepaired code. A probe that cannot
|
||||
* go red is not a probe.
|
||||
*
|
||||
* <p>So the height-less pair is DELETED rather than deprecated, and both survivors are abstract.
|
||||
* The compiler is now the negative control: any implementation, test double included, that cannot
|
||||
@ -55,7 +56,7 @@ public interface PqSignerRegistry {
|
||||
* on {@link FalconSealSupport} and under a name that cannot be mistaken for a verification path -
|
||||
* see {@code FalconSealSupport.localSigningAddress()}.
|
||||
*
|
||||
* <p>D2 HARDENING (b-v2), 2026-08-06: this is the HISTORY half of the pair. It is reachable
|
||||
* <p>LOOKUP HARDENING (b-v2), 2026-08-06: this is the HISTORY half of the pair. It is reachable
|
||||
* only from the two header-validation rules, and it REFUSES an unbound height at or above the
|
||||
* arming height. The own-head half is {@link #addressForIndexAtOwnHead}, which carries the
|
||||
* measurement that forced the split.
|
||||
@ -68,16 +69,18 @@ public interface PqSignerRegistry {
|
||||
Address addressForIndexAtHistoric(long blockNumber, int validatorIndex);
|
||||
|
||||
/**
|
||||
* D2 HARDENING (b-v2). The address bound to a registry index at a height, asked about THIS NODE'S
|
||||
* LOOKUP HARDENING (b-v2). The address bound to a registry index at a height, asked about THIS
|
||||
* NODE'S
|
||||
* OWN HEAD: a block this node is building, or the head it has just restarted onto.
|
||||
*
|
||||
* <p>WHY THIS SECOND NAME EXISTS, and it is a measurement and not a taste. The first shape of
|
||||
* hardening (b) refused every unbound height at or above the arming height and decided that from
|
||||
* the block NUMBER alone. On 2026-08-06 that turned six tests red - five in {@code
|
||||
* PqSealPersistenceTest}, the restart path, and one in {@code PqForkValidatorSetChangeTest}, the
|
||||
* proposer - and the D078 message states the operational consequence in one line: the node stops
|
||||
* producing blocks. In all six the number handed to the guard was 1030 with an arming height of
|
||||
* 1000, which is exactly what a genuinely historical question at the same instant would hand it.
|
||||
* proposer - and that test's message states the operational consequence in one line: the node
|
||||
* stops producing blocks. In all six the number handed to the guard was 1030 with an arming
|
||||
* height of 1000, which is exactly what a genuinely historical question at the same instant would
|
||||
* hand it.
|
||||
* There is no arithmetic on the height that separates the two. What separates them is WHO SUPPLIES
|
||||
* THE SUBJECT, and that is known at every call site and was being thrown away at the boundary.
|
||||
*
|
||||
@ -96,7 +99,8 @@ public interface PqSignerRegistry {
|
||||
Address addressForIndexAtOwnHead(long blockNumber, int validatorIndex);
|
||||
|
||||
/**
|
||||
* D-081 / D2 HARDENING (a). Verify a Falcon signature by a registry index AT A HEIGHT. Must never
|
||||
* HEIGHT-INDEXED REGISTRY, LOOKUP HARDENING (a). Verify a Falcon signature by a registry index AT
|
||||
* A HEIGHT. Must never
|
||||
* throw. Abstract for the reason given on {@link #addressForIndexAtHistoric}.
|
||||
*
|
||||
* @param blockNumber the height of the header carrying the seal
|
||||
@ -108,7 +112,8 @@ public interface PqSignerRegistry {
|
||||
boolean verifyAtHistoric(long blockNumber, int validatorIndex, Bytes message, Bytes signature);
|
||||
|
||||
/**
|
||||
* D2 HARDENING (b-v2). Verify a Falcon signature over a block THIS NODE holds as its own head or
|
||||
* LOOKUP HARDENING (b-v2). Verify a Falcon signature over a block THIS NODE holds as its own head
|
||||
* or
|
||||
* is building right now. Never refuses for a missing height binding; see {@link
|
||||
* #addressForIndexAtOwnHead} for the measurement that forced the split and for what it still does
|
||||
* refuse.
|
||||
|
||||
@ -1,69 +0,0 @@
|
||||
/* AERE crypto-agility, step 2: a seal that names its scheme. The legacy FalconSeal cannot say
|
||||
* what mathematics signed it, so a certificate of FalconSeals can never carry a hybrid. This one
|
||||
* carries the one-byte scheme wire tag from {@link SealSchemes}, which is the whole difference. */
|
||||
package org.hyperledger.besu.consensus.common.bft;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
import org.apache.tuweni.bytes.Bytes;
|
||||
|
||||
/** One validator seal tagged with the scheme that produced it. Immutable. */
|
||||
public final class SchemeSeal {
|
||||
|
||||
private final byte schemeWireId;
|
||||
private final int validatorIndex;
|
||||
private final Bytes signature;
|
||||
|
||||
/** @param schemeWireId the {@link SealScheme#wireId()} of the producing scheme
|
||||
* @param validatorIndex the signer registry index, non-negative
|
||||
* @param signature the raw signature bytes */
|
||||
public SchemeSeal(final byte schemeWireId, final int validatorIndex, final Bytes signature) {
|
||||
this.schemeWireId = schemeWireId;
|
||||
this.validatorIndex = validatorIndex;
|
||||
this.signature = signature;
|
||||
}
|
||||
|
||||
/** The wire tag of the scheme that produced this seal. */
|
||||
public byte getSchemeWireId() {
|
||||
return schemeWireId;
|
||||
}
|
||||
|
||||
/** The signer registry index. */
|
||||
public int getValidatorIndex() {
|
||||
return validatorIndex;
|
||||
}
|
||||
|
||||
/** The raw signature bytes. */
|
||||
public Bytes getSignature() {
|
||||
return signature;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(final Object o) {
|
||||
if (this == o) {
|
||||
return true;
|
||||
}
|
||||
if (!(o instanceof SchemeSeal that)) {
|
||||
return false;
|
||||
}
|
||||
return schemeWireId == that.schemeWireId
|
||||
&& validatorIndex == that.validatorIndex
|
||||
&& Objects.equals(signature, that.signature);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(schemeWireId, validatorIndex, signature);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "SchemeSeal{scheme=0x"
|
||||
+ Integer.toHexString(schemeWireId & 0xff)
|
||||
+ ", index="
|
||||
+ validatorIndex
|
||||
+ ", sig="
|
||||
+ signature.size()
|
||||
+ "B}";
|
||||
}
|
||||
}
|
||||
@ -1,96 +0,0 @@
|
||||
/*
|
||||
* AERE crypto-agility layer, step 1 (2026-08-24, TOP-3 list item 9).
|
||||
*
|
||||
* WHY THIS EXISTS. Until today the anchor certificate code talked to exactly one algorithm,
|
||||
* Falcon-512, by name: FalconSeal, FalconSealSupport, FalconPublicKeyParameters. "Safe when the
|
||||
* math changes" was a slogan the code could not honour, because changing the math meant editing
|
||||
* every call site. This interface is the seam that makes the slogan checkable: the protocol talks
|
||||
* to a SealScheme; which lattice (or hash) sits behind it is configuration.
|
||||
*
|
||||
* WHAT IT DELIBERATELY IS NOT. It does not touch FalconSealSupport yet (that rewiring is step 2,
|
||||
* and that file is an overwrite-class file under the D-152 patch discipline). It does not load
|
||||
* private keys from disk (production loading stays per-scheme, exactly as today). It does not
|
||||
* invent a private-key wire encoding: private keys live only as in-memory handles, so no new
|
||||
* secret format exists to leak or to get wrong.
|
||||
*/
|
||||
package org.hyperledger.besu.consensus.common.bft;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Optional;
|
||||
|
||||
/** A pluggable post-quantum signature scheme for validator seals. Implementations never throw
|
||||
* from {@link #verify}: a malformed key or signature is simply an invalid seal. */
|
||||
public interface SealScheme {
|
||||
|
||||
/** Stable human-readable identifier, e.g. {@code "falcon-512"}. Matches the naming the chain
|
||||
* already uses publicly (precompile docs, /v1/pq/verify schemes). */
|
||||
String id();
|
||||
|
||||
/** One-byte wire tag reserved for the versioned certificate format (v2) in which each seal
|
||||
* names its scheme. 0x00 is reserved for "unversioned legacy Falcon". */
|
||||
byte wireId();
|
||||
|
||||
/** Parse the registry form of a public key (the exact bytes a signer registry stores).
|
||||
* Empty when the bytes cannot be a key of this scheme. */
|
||||
Optional<PublicHandle> parsePublicKey(byte[] registryForm);
|
||||
|
||||
/** The registry-form length in bytes, so registries can sanity-check entries per scheme. */
|
||||
int publicKeyLength();
|
||||
|
||||
/** Sign a message. Empty on any failure; never throws. */
|
||||
Optional<byte[]> sign(PrivateHandle key, byte[] message);
|
||||
|
||||
/** Verify. False on any failure, including a handle from another scheme; never throws. */
|
||||
boolean verify(PublicHandle key, byte[] message, byte[] signature);
|
||||
|
||||
/** Convenience: parse-then-verify straight from registry bytes. False on any failure. */
|
||||
default boolean verifyRaw(final byte[] registryForm, final byte[] message, final byte[] signature) {
|
||||
try {
|
||||
return parsePublicKey(registryForm).map(k -> verify(k, message, signature)).orElse(false);
|
||||
} catch (final RuntimeException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Generate a fresh key pair. Used by test networks only: real validator keys are born in the
|
||||
* vault ceremony, never inside a node. */
|
||||
GeneratedPair generate(SecureRandom random);
|
||||
|
||||
/**
|
||||
* The scheme's OWN canonical private-key encoding, when it has one. Empty by default.
|
||||
*
|
||||
* <p>DATED NOTE 2026-08-25, refining the sentence at the top of this file. The layer
|
||||
* still does NOT invent a private-key format: the methods below expose exactly the
|
||||
* encoding the scheme's library already has, and only schemes that truly have one
|
||||
* implement them. Measured today on the shipped jar: SLH-DSA-128s has {@code getEncoded()}
|
||||
* with an exact round-trip, so it implements them; Falcon-512 keeps its key in components
|
||||
* and its PRODUCTION loading stays untouched in FalconSealSupport, so it does NOT implement
|
||||
* them and returns empty. Why it was needed: the hybrid producer must be able to receive
|
||||
* the second scheme's key without every call site knowing which scheme it is.
|
||||
*
|
||||
* @param key the private handle
|
||||
* @return the encoding, or empty when this scheme has no canonical one
|
||||
*/
|
||||
default Optional<byte[]> serializePrivateKey(final PrivateHandle key) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild a private handle from {@link #serializePrivateKey}. Empty on anything unusable.
|
||||
*
|
||||
* @param raw the encoding
|
||||
* @return the handle, or empty
|
||||
*/
|
||||
default Optional<PrivateHandle> parsePrivateKey(final byte[] raw) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
/** Opaque scheme-specific public key. */
|
||||
interface PublicHandle {}
|
||||
|
||||
/** Opaque scheme-specific private key. Never serialised by this layer. */
|
||||
interface PrivateHandle {}
|
||||
|
||||
/** A freshly generated pair plus the registry form of its public key. */
|
||||
record GeneratedPair(PublicHandle publicKey, PrivateHandle privateKey, byte[] publicRegistryForm) {}
|
||||
}
|
||||
@ -1,53 +0,0 @@
|
||||
/* AERE crypto-agility: the scheme registry. The protocol asks here by name or wire tag and gets
|
||||
* an algorithm; swapping the mathematics becomes configuration plus keys, never call-site edits.
|
||||
* Wire tags are the certificate-v2 vocabulary: 0x00 stays reserved for the unversioned legacy
|
||||
* Falcon certificate already live on chain 2800, so old certificates can never be confused with
|
||||
* tagged ones. */
|
||||
package org.hyperledger.besu.consensus.common.bft;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Static registry of the seal schemes this build understands. */
|
||||
public final class SealSchemes {
|
||||
|
||||
/** Falcon-512 (lattice), the scheme live on chain 2800 today. */
|
||||
public static final SealScheme FALCON_512 = new FalconSealScheme();
|
||||
|
||||
/** SLH-DSA-128s (hash-based, FIPS 205), the founder-approved hybrid counterpart. */
|
||||
public static final SealScheme SLH_DSA_128S = new SlhDsaSealScheme();
|
||||
|
||||
private static final List<SealScheme> ALL = List.of(FALCON_512, SLH_DSA_128S);
|
||||
|
||||
private SealSchemes() {}
|
||||
|
||||
/** All schemes this build understands, in wire-tag order. */
|
||||
public static List<SealScheme> all() {
|
||||
return ALL;
|
||||
}
|
||||
|
||||
/** Look up by stable id, e.g. {@code "falcon-512"}. Empty for unknown ids: an unknown scheme
|
||||
* must be a loud configuration error at the caller, never a silent default. */
|
||||
public static Optional<SealScheme> byId(final String id) {
|
||||
if (id == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
for (final SealScheme s : ALL) {
|
||||
if (s.id().equals(id)) {
|
||||
return Optional.of(s);
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
/** Look up by certificate-v2 wire tag. Empty for 0x00 (legacy, not a tagged scheme) and for
|
||||
* anything unknown. */
|
||||
public static Optional<SealScheme> byWireId(final byte wireId) {
|
||||
for (final SealScheme s : ALL) {
|
||||
if (s.wireId() == wireId) {
|
||||
return Optional.of(s);
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
@ -1,123 +0,0 @@
|
||||
/* AERE crypto-agility: SLH-DSA-128s (NIST FIPS 205, the standardised SPHINCS+) behind the
|
||||
* SealScheme seam. This is the second half of the founder-approved hybrid direction of
|
||||
* 2026-08-07 (option 3): hash-based security alongside lattice-based Falcon, so that a break in
|
||||
* either mathematics leaves the other standing. The scheme name matches the precompile the chain
|
||||
* already runs at 0x0AE4 since block 9,189,161, so the public naming stays consistent.
|
||||
*
|
||||
* NOTE ON KEYS: introducing this scheme creates NO keys anywhere. Real hybrid validator keys
|
||||
* require a separate founder-approved ceremony (standing rule, 2026-08-07); test networks
|
||||
* generate throwaway pairs per run via {@link #generate}. */
|
||||
package org.hyperledger.besu.consensus.common.bft;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.bouncycastle.crypto.AsymmetricCipherKeyPair;
|
||||
import org.bouncycastle.pqc.crypto.slhdsa.SLHDSAKeyGenerationParameters;
|
||||
import org.bouncycastle.pqc.crypto.slhdsa.SLHDSAKeyPairGenerator;
|
||||
import org.bouncycastle.pqc.crypto.slhdsa.SLHDSAParameters;
|
||||
import org.bouncycastle.pqc.crypto.slhdsa.SLHDSAPrivateKeyParameters;
|
||||
import org.bouncycastle.pqc.crypto.slhdsa.SLHDSAPublicKeyParameters;
|
||||
import org.bouncycastle.pqc.crypto.slhdsa.SLHDSASigner;
|
||||
|
||||
/** SLH-DSA-128s (small, SHA2 family) as a pluggable seal scheme. */
|
||||
public final class SlhDsaSealScheme implements SealScheme {
|
||||
|
||||
/** Registry form: the encoded SLH-DSA-128s public key (PK.seed || PK.root), 32 bytes. */
|
||||
public static final int PUBLIC_KEY_LENGTH = 32;
|
||||
|
||||
private static final SLHDSAParameters PARAMS = SLHDSAParameters.sha2_128s;
|
||||
|
||||
private record Pub(SLHDSAPublicKeyParameters params) implements PublicHandle {}
|
||||
|
||||
private record Priv(SLHDSAPrivateKeyParameters params) implements PrivateHandle {}
|
||||
|
||||
@Override
|
||||
public String id() {
|
||||
return "slh-dsa-128s";
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte wireId() {
|
||||
return 0x02;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int publicKeyLength() {
|
||||
return PUBLIC_KEY_LENGTH;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<PublicHandle> parsePublicKey(final byte[] registryForm) {
|
||||
if (registryForm == null || registryForm.length != PUBLIC_KEY_LENGTH) {
|
||||
return Optional.empty();
|
||||
}
|
||||
try {
|
||||
return Optional.of(new Pub(new SLHDSAPublicKeyParameters(PARAMS, registryForm)));
|
||||
} catch (final RuntimeException e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<byte[]> sign(final PrivateHandle key, final byte[] message) {
|
||||
if (!(key instanceof Priv p) || message == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
try {
|
||||
final SLHDSASigner signer = new SLHDSASigner();
|
||||
signer.init(true, p.params());
|
||||
return Optional.of(signer.generateSignature(message));
|
||||
} catch (final RuntimeException e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean verify(final PublicHandle key, final byte[] message, final byte[] signature) {
|
||||
if (!(key instanceof Pub p) || message == null || signature == null) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
final SLHDSASigner verifier = new SLHDSASigner();
|
||||
verifier.init(false, p.params());
|
||||
return verifier.verifySignature(message, signature);
|
||||
} catch (final RuntimeException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<byte[]> serializePrivateKey(final PrivateHandle key) {
|
||||
if (!(key instanceof Priv p)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
try {
|
||||
return Optional.ofNullable(p.params().getEncoded());
|
||||
} catch (final RuntimeException e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<PrivateHandle> parsePrivateKey(final byte[] raw) {
|
||||
if (raw == null || raw.length == 0) {
|
||||
return Optional.empty();
|
||||
}
|
||||
try {
|
||||
return Optional.of(new Priv(new SLHDSAPrivateKeyParameters(PARAMS, raw)));
|
||||
} catch (final RuntimeException e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public GeneratedPair generate(final SecureRandom random) {
|
||||
final SLHDSAKeyPairGenerator gen = new SLHDSAKeyPairGenerator();
|
||||
gen.init(new SLHDSAKeyGenerationParameters(random, PARAMS));
|
||||
final AsymmetricCipherKeyPair pair = gen.generateKeyPair();
|
||||
final SLHDSAPublicKeyParameters pub = (SLHDSAPublicKeyParameters) pair.getPublic();
|
||||
final SLHDSAPrivateKeyParameters priv = (SLHDSAPrivateKeyParameters) pair.getPrivate();
|
||||
return new GeneratedPair(new Pub(pub), new Priv(priv), pub.getEncoded());
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright contributors to Besu / AERE Network.
|
||||
* Copyright contributors to Besu / Aere Network.
|
||||
*
|
||||
* 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
|
||||
@ -202,14 +202,14 @@ public final class PqAnchorProducer {
|
||||
final int sealCap = cfg.maxSealsCarried().orElse(Integer.MAX_VALUE);
|
||||
int rejectedIneligible = 0;
|
||||
int rejectedInvalid = 0;
|
||||
// D2 (2026-08-06): the proposer resolves keys AT THE PARENT'S HEIGHT, the same height R2 will
|
||||
// use when it re-checks this certificate. Before this the producer asked a height-less registry
|
||||
// while R2 asked a height-resolved one, so at a rotation height the two could disagree about
|
||||
// which key set applies - the proposer would assemble a certificate the fleet then refuses, and
|
||||
// the round would fail for a reason no log named. Building and validating now read the same
|
||||
// question.
|
||||
// HEIGHT-RESOLVED LOOKUP (2026-08-06): the proposer resolves keys AT THE PARENT'S HEIGHT, the
|
||||
// same height R2 will use when it re-checks this certificate. Before this the producer asked a
|
||||
// height-less registry while R2 asked a height-resolved one, so at a rotation height the two
|
||||
// could disagree about which key set applies - the proposer would assemble a certificate the
|
||||
// fleet then refuses, and the round would fail for a reason no log named. Building and
|
||||
// validating now read the same question.
|
||||
for (final FalconSeal seal : PqAnchor.sortedByIndex(heard)) {
|
||||
// D2 (b-v2): the OWN-HEAD door. parentHeader is this node's own head - this method is
|
||||
// OWN-HEAD DOOR (b-v2): parentHeader is this node's own head - this method is
|
||||
// reached only from the proposer, building the block on top of it. Refusing here for a
|
||||
// missing schedule is what stopped block production in PqForkValidatorSetChangeTest.
|
||||
final Address signer =
|
||||
@ -227,9 +227,9 @@ public final class PqAnchorProducer {
|
||||
// THE COST CAP. K is a FLOOR, not a ceiling: without this break the proposer writes every
|
||||
// eligible seal it happened to hear, so a K=3 chain at N=7 carries four, five, six or seven.
|
||||
// Measured 2026-08-07 on a live seven-node run with the threshold at 4: 42 blocks carried 4
|
||||
// seals, 36 carried 5, 5 carried 6. At 666 bytes a seal that is 200.9 GB per node per year
|
||||
// instead of 120.5, and the extra buys NOTHING: what a verifier demands is the threshold, not
|
||||
// how many seals a proposer volunteers above it.
|
||||
// seals, 36 carried 5, 5 carried 6. At 666 bytes a seal that is roughly 1.7 times the header
|
||||
// bytes the same chain would write capped at K, and the extra buys NOTHING: what a verifier
|
||||
// demands is the threshold, not how many seals a proposer volunteers above it.
|
||||
//
|
||||
// The break is safe precisely because it is placed AFTER the eligibility and signature checks:
|
||||
// every seal counted here has already been verified, so stopping at the cap can never leave
|
||||
|
||||
@ -20,8 +20,8 @@ import java.nio.file.Paths;
|
||||
import org.hyperledger.besu.consensus.common.bft.PqRegistryHash;
|
||||
|
||||
/**
|
||||
* AERE A8: compute the canonical hash of a Falcon validator registry so it can be put into genesis
|
||||
* as {@code config.pqRegistryHash}.
|
||||
* AERE GENESIS BINDING: compute the canonical hash of a Falcon validator registry so it can be put
|
||||
* into genesis as {@code config.pqRegistryHash}.
|
||||
*
|
||||
* <p>This tool calls THE SAME code the startup guard calls. That is the whole point of it existing
|
||||
* as a class inside {@code consensus:common} rather than as a shell script: a tool that computed the
|
||||
@ -94,11 +94,11 @@ public final class PqRegistryHashTool {
|
||||
return;
|
||||
}
|
||||
|
||||
// AERE D-C (2026-08-06). hashFor, not hashV1, and this is the line an operator pastes into
|
||||
// genesis. For a proof-bound (v2) registry the node's gate compares hashV2; printing hashV1 here
|
||||
// gives the whole fleet a value NOTHING on a node ever computes. Measured on a network of seven
|
||||
// on 2026-08-06: all seven start, all seven report the registry loaded, and the chain stops at
|
||||
// H-1. A wrong instruction is more dangerous than a missing one.
|
||||
// AERE CANONICAL FINGERPRINT (2026-08-06). hashFor, not hashV1, and this is the line an
|
||||
// operator pastes into genesis. For a proof-bound (v2) registry the node's gate compares
|
||||
// hashV2; printing hashV1 here gives the whole fleet a value NOTHING on a node ever computes.
|
||||
// Measured on a network of seven on 2026-08-06: all seven start, all seven report the registry
|
||||
// loaded, and the chain stops at H-1. A wrong instruction is more dangerous than a missing one.
|
||||
//
|
||||
// hashV1 is also useless as an epoch identifier, which is the other reason it cannot merely be
|
||||
// kept alongside: the bind height is not in the v1 pre-image, so two rotation epochs of the same
|
||||
@ -110,7 +110,7 @@ public final class PqRegistryHashTool {
|
||||
return;
|
||||
}
|
||||
|
||||
System.out.println("AERE PQC A8 - canonical Falcon registry hash");
|
||||
System.out.println("AERE PQC GENESIS-BINDING - canonical Falcon registry hash");
|
||||
System.out.println(" file : " + reg.sourcePath());
|
||||
System.out.println(" source kind : " + reg.kind());
|
||||
System.out.println(" entries : " + reg.count());
|
||||
@ -130,7 +130,7 @@ public final class PqRegistryHashTool {
|
||||
if (reg.proofBound()) {
|
||||
System.out.println();
|
||||
System.out.println(
|
||||
" AERE D-B: schedule this registry at block "
|
||||
" AERE HEIGHT BINDING: schedule this registry at block "
|
||||
+ reg.bindHeight()
|
||||
+ " AND NOWHERE ELSE. Every row's possession proof and validator claim sign that");
|
||||
System.out.println(
|
||||
@ -160,10 +160,10 @@ public final class PqRegistryHashTool {
|
||||
System.out.println();
|
||||
System.out.println(" Paste into genesis under \"config\":");
|
||||
System.out.println();
|
||||
// AERE D-B: the recipe this tool prints has to be the recipe the node accepts. Since 2026-08-06
|
||||
// a proof-bound registry scheduled at a block other than its bindHeight is refused at startup,
|
||||
// so printing one here would be manufacturing the configuration the node rejects - in a file
|
||||
// that goes to all seven nodes at once.
|
||||
// AERE HEIGHT BINDING: the recipe this tool prints has to be the recipe the node accepts.
|
||||
// Since 2026-08-06 a proof-bound registry scheduled at a block other than its bindHeight is
|
||||
// refused at startup, so printing one here would be manufacturing the configuration the node
|
||||
// rejects - in a file that goes to all seven nodes at once.
|
||||
final long at = block == 0L && reg.proofBound() ? reg.bindHeight() : block;
|
||||
if (reg.proofBound() && at != reg.bindHeight()) {
|
||||
System.err.println();
|
||||
|
||||
@ -1,354 +0,0 @@
|
||||
/*
|
||||
* Copyright contributors to Besu / AERE Network.
|
||||
*
|
||||
* 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.assertThatCode;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import org.apache.tuweni.bytes.Bytes;
|
||||
import org.bouncycastle.crypto.digests.KeccakDigest;
|
||||
import org.bouncycastle.pqc.crypto.falcon.FalconPrivateKeyParameters;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
/**
|
||||
* D-078, THE HALF THAT WAS STILL OPEN: is the threshold K one the fleet can be GUARANTEED to meet?
|
||||
*
|
||||
* <p>The 2026-08-02 repair closed the mechanism that stopped the chain on one add-validator vote: it
|
||||
* took the fleet-wide coverage question out of the per-commit attachment gate and made coverage a
|
||||
* REPORT. That repair is correct and it is measured next door in {@code D078ValidatorSetChangeTest}.
|
||||
* But it left behind an explicit promise, written in the javadoc of {@code attachmentArmed}:
|
||||
*
|
||||
* <blockquote>
|
||||
*
|
||||
* "What coverage genuinely protects - that blocking is not ARMED over a partial manifest - is an
|
||||
* arm-time decision, and it is made at arm time by armingReadinessDiagnostic() and by the operator".
|
||||
*
|
||||
* </blockquote>
|
||||
*
|
||||
* <p>MEASURED 2026-08-03: {@code armingReadinessDiagnostic()} checks exactly one thing, whether the
|
||||
* manifest is ADDRESS-BOUND. It never reads the fleet size, never reads how many validators hold an
|
||||
* anchored key, and never reads K. The arm-time decision the comment names did not exist, so the
|
||||
* compensating control for the repair was a sentence. This class is what makes it exist.
|
||||
*
|
||||
* <p>THE ARITHMETIC, which is the whole finding and is not an opinion. A block needs {@code
|
||||
* ceil(2N/3)} ECDSA committers, and Falcon seals ride on Commit messages, so the seals a proposer is
|
||||
* GUARANTEED to hear are only those of the keyed validators it cannot avoid: {@code quorum - (N -
|
||||
* keyed)}. The row that matters for this project:
|
||||
*
|
||||
* <pre>
|
||||
* N=7, keyed 7, quorum 5 -> 5 guaranteed K=5 reachable, margin exactly 0
|
||||
* N=9, keyed 7, quorum 6 -> 4 guaranteed K=5 NOT guaranteed
|
||||
* </pre>
|
||||
*
|
||||
* <p>The second row is the standing plan. "Grow to N=9 BEFORE arming" is right, and if the manifest
|
||||
* is not re-anchored on the way there it produces a fleet that arms a threshold no proposer is
|
||||
* guaranteed to meet. Before this guard a node in that state started, joined, armed, and the failure
|
||||
* appeared later as a proposer that could not propose. That is the most expensive shape a
|
||||
* configuration error can take, and it is the same shape the A8 repair already refused to allow for
|
||||
* a non-address-bound manifest.
|
||||
*
|
||||
* <p>WHY AT CONFIG TIME AND NOWHERE ELSE. The lesson is borrowed, not invented: CometBFT applies a
|
||||
* validator-set change only at H+2 and Ethereum's light-client protocol carries {@code
|
||||
* next_sync_committee} a whole period ahead, both so that the set a cryptographic check runs over is
|
||||
* known and comparable BEFORE the boundary rather than discovered at it. We cannot copy their
|
||||
* mechanism, because at seven nodes under one operator there is no committee to sample. We can copy
|
||||
* the discipline: DECLARE the fleet size, compare it against the threshold at config time, and
|
||||
* refuse to cross the boundary if the comparison fails. The same reasoning already produced
|
||||
* AERE-PQC-CFG-UNSAFE-04 and, for the fork height, AERE-PQC-CFG-UNSAFE-06/07 in D-079.
|
||||
*
|
||||
* <p>NOT MEASURED here, and named so it is not read as covered: what a LIVE fleet does in the rounds
|
||||
* between the vote landing and the first proposer failing. That needs a network. This class measures
|
||||
* the decision, which is the thing a node can be stopped from taking.
|
||||
*/
|
||||
public class D078ThresholdReachabilityTest {
|
||||
|
||||
/** Anchor activation height H. */
|
||||
private static final long H = 1_000L;
|
||||
|
||||
/** The height from which the staged threshold is K. */
|
||||
private static final long K_AT = H + 10L;
|
||||
|
||||
/** The threshold this project intends to arm. */
|
||||
private static final int K = 5;
|
||||
|
||||
/**
|
||||
* AERE D-146: the chain the registries this fixture writes are BOUND to. It is the same value
|
||||
* {@link #armAnchor} states in {@code aere.pq.chainId}: a registry bound to one chain and an
|
||||
* anchor armed on another is a configuration this fixture must never accidentally describe.
|
||||
*/
|
||||
private static final long CHAIN_ID = 2_800L;
|
||||
|
||||
@TempDir private Path tmp;
|
||||
|
||||
@BeforeEach
|
||||
public void setUp() throws Exception {
|
||||
resetFalconSingleton();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
public void tearDown() throws Exception {
|
||||
for (final String p :
|
||||
new String[] {
|
||||
"aere.falcon.genesis",
|
||||
"aere.falcon.key",
|
||||
"aere.falcon.attachBlock",
|
||||
"aere.falcon.validatorCount",
|
||||
"aere.falcon.testnetAllowSmallFleet",
|
||||
PqAnchorConfig.PROPERTY_ANCHOR_BLOCK,
|
||||
PqAnchorConfig.PROPERTY_MIN_SEALS,
|
||||
PqAnchorConfig.PROPERTY_CHAIN_ID
|
||||
}) {
|
||||
System.clearProperty(p);
|
||||
}
|
||||
// DATED 2026-08-20, the SECOND time this exact leak was paid for. armAnchor() plus
|
||||
// FalconSealSupport.instance() builds the anchor config through PqAnchorProducer.config(),
|
||||
// whose once-per-JVM cache outlives every property cleared above. Measured today on the
|
||||
// production tree: the armed config this class caches turned all five PqFleetRestartArmingTest
|
||||
// fixtures into AERE-PQC-REG-ARM-02 refusals, green alone, red in the suite, identical sources.
|
||||
// The twin (PqForkThresholdReachabilityTest) has carried this line since 2026-08-11 with the
|
||||
// same story; this class was forked before that fix and never received it.
|
||||
org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer.useConfigForTesting(
|
||||
null);
|
||||
resetFalconSingleton();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// 1. THE FINDING. A threshold the fleet is not guaranteed to meet must not start.
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
public void armingAThresholdTheFleetCannotGuaranteeMustRefuseToStart() throws Exception {
|
||||
// The exact state the standing plan walks through: the set has grown to nine, the anchored
|
||||
// manifest still names the original seven, and the threshold is the one the schedule arms.
|
||||
writeAnchoredRegistry(7);
|
||||
System.setProperty("aere.falcon.validatorCount", "9");
|
||||
armAnchor(K);
|
||||
|
||||
assertThatThrownBy(FalconSealSupport::instance)
|
||||
.describedAs(
|
||||
"N=9 with 7 keyed guarantees only %d Falcon seal(s) among a block's committers, and the "
|
||||
+ "armed threshold is K=%d. A node must refuse to start rather than arm a threshold "
|
||||
+ "no proposer is guaranteed to be able to meet.",
|
||||
FalconSealSupport.worstCaseKeyedSigners(9, 7), K)
|
||||
.isInstanceOf(FalconSealSupport.ActivationConfigException.class)
|
||||
.hasMessageContaining("AERE-PQC-CFG-UNSAFE-08")
|
||||
// The message has to carry BOTH numbers. "Unsafe" without them sends an operator to read
|
||||
// code; the two numbers are the whole diagnosis and the whole remedy.
|
||||
.hasMessageContaining("K=" + K)
|
||||
.hasMessageContaining("guaranteed");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// 2. NEGATIVE CONTROL. A guard that refuses everything is not a guard.
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
public void aReachableThresholdMustStillStart() throws Exception {
|
||||
// N=7 fully keyed: quorum 5, guaranteed 5, K=5. Margin is exactly zero, which is a different
|
||||
// statement from "unreachable", and the guard must not confuse the two. This is also the
|
||||
// configuration the fleet runs today, so a guard that refused it would be a self-inflicted halt.
|
||||
writeAnchoredRegistry(7);
|
||||
System.setProperty("aere.falcon.validatorCount", "7");
|
||||
armAnchor(K);
|
||||
|
||||
assertThatCode(FalconSealSupport::instance)
|
||||
.describedAs("N=7 fully keyed guarantees exactly K=%d; zero margin is not unreachable", K)
|
||||
.doesNotThrowAnyException();
|
||||
assertThat(FalconSealSupport.instance().registrySize()).isEqualTo(7);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void growingTheManifestWithTheSetIsWhatMakesNineSafe() throws Exception {
|
||||
// The remedy the refusal names, measured rather than asserted: re-anchor the manifest for the
|
||||
// whole set and the same N=9, same K=5 starts.
|
||||
writeAnchoredRegistry(9);
|
||||
System.setProperty("aere.falcon.validatorCount", "9");
|
||||
armAnchor(K);
|
||||
|
||||
assertThatCode(FalconSealSupport::instance).doesNotThrowAnyException();
|
||||
assertThat(FalconSealSupport.worstCaseKeyedSigners(9, 9))
|
||||
.describedAs("nine keyed of nine guarantees the full ECDSA quorum")
|
||||
.isEqualTo(6);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// 3. INERT WHERE IT MUST BE INERT. Chain 2800 as it stands today.
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
public void withNoAnchorConfiguredTheGuardIsInert() throws Exception {
|
||||
// aere.pq.anchorBlock is UNSET on the live chain, so K does not exist and there is nothing to
|
||||
// compare. A guard that could stop a node in that state would be a new way to lose the fleet,
|
||||
// which is a strictly worse defect than the one it repairs.
|
||||
writeAnchoredRegistry(7);
|
||||
System.setProperty("aere.falcon.validatorCount", "9");
|
||||
|
||||
assertThatCode(FalconSealSupport::instance)
|
||||
.describedAs("no anchor configured: no threshold, no comparison, no refusal")
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aScheduleThatNeverRaisesTheThresholdAboveZeroIsInert() throws Exception {
|
||||
writeAnchoredRegistry(7);
|
||||
System.setProperty("aere.falcon.validatorCount", "9");
|
||||
armAnchor(0);
|
||||
|
||||
assertThatCode(FalconSealSupport::instance)
|
||||
.describedAs("K=0 everywhere is the warm-up regime; nothing can fail to be met")
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// 4. THE CASE WITH NO KEYS AT ALL, which is the same arithmetic at its floor.
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
public void aPositiveThresholdWithNoAnchoredKeysMustRefuseToStart() throws Exception {
|
||||
// No manifest anywhere and K=5: guaranteed is 0, so every block at or above H would be rejected
|
||||
// for want of a certificate nobody can produce. Distinct from the A8 refusal, which only fires
|
||||
// when aere.falcon.forkBlock is set; the anchor path has its own arming height.
|
||||
System.setProperty("aere.falcon.validatorCount", "7");
|
||||
armAnchor(K);
|
||||
|
||||
assertThatThrownBy(FalconSealSupport::instance)
|
||||
.isInstanceOf(FalconSealSupport.ActivationConfigException.class)
|
||||
.hasMessageContaining("AERE-PQC-CFG-UNSAFE-08");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// 5. THE WAIVER IS EXPLICIT, NAMED, AND ONLY FOR ISOLATED NETWORKS.
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
public void anIsolatedTestNetworkCanWaiveTheGuardExplicitly() throws Exception {
|
||||
writeAnchoredRegistry(7);
|
||||
System.setProperty("aere.falcon.validatorCount", "9");
|
||||
System.setProperty("aere.falcon.testnetAllowSmallFleet", "true");
|
||||
armAnchor(K);
|
||||
|
||||
assertThatCode(FalconSealSupport::instance)
|
||||
.describedAs(
|
||||
"the same switch that waives the N>=9 rule waives this one, because both say the same "
|
||||
+ "thing: this fleet has no Falcon fault margin and must not be a mainnet")
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// 6. THE ARITHMETIC ITSELF, at the boundary, as a pure function.
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
public void theDeficitIsTheDistanceBetweenTheThresholdAndTheGuarantee() {
|
||||
assertThat(FalconSealSupport.thresholdDeficit(7, 7, 5))
|
||||
.describedAs("N=7 fully keyed meets K=5 exactly")
|
||||
.isZero();
|
||||
assertThat(FalconSealSupport.thresholdDeficit(8, 7, 5))
|
||||
.describedAs("one unkeyed validator added: still met")
|
||||
.isZero();
|
||||
assertThat(FalconSealSupport.thresholdDeficit(9, 7, 5))
|
||||
.describedAs("two added without re-anchoring: short by one, which is the halt")
|
||||
.isEqualTo(1);
|
||||
assertThat(FalconSealSupport.thresholdDeficit(9, 9, 5)).isZero();
|
||||
assertThat(FalconSealSupport.thresholdDeficit(7, 0, 1))
|
||||
.describedAs("no keys at all: a positive threshold is short by all of it")
|
||||
.isEqualTo(1);
|
||||
assertThat(FalconSealSupport.thresholdDeficit(7, 7, 0))
|
||||
.describedAs("K=0 can never be in deficit")
|
||||
.isZero();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// Helpers.
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
/** Arm the V2 anchor from system configuration with a staged threshold that reaches {@code k}. */
|
||||
private static void armAnchor(final int k) {
|
||||
System.setProperty(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, Long.toString(H));
|
||||
// AERE CONFIGURATIE-STRICTA (2026-08-06): an activation height without an explicit
|
||||
// chain id is now a startup refusal, because a silently defaulted 0 in the D and M
|
||||
// pre-images is the Holesky shape. The fixture states what the fleet states.
|
||||
System.setProperty(PqAnchorConfig.PROPERTY_CHAIN_ID, Long.toString(CHAIN_ID));
|
||||
System.setProperty(
|
||||
PqAnchorConfig.PROPERTY_MIN_SEALS, H + ":0," + K_AT + ":" + k);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a genesis-anchored, address-bound Falcon manifest for {@code count} validators and point
|
||||
* this node at index 0's key, exactly as {@code D078ValidatorSetChangeTest} does. The anchored hash
|
||||
* is accumulated in lockstep with the manifest text, so the fixture is anchored the way a real
|
||||
* genesis is rather than by a flag.
|
||||
*/
|
||||
private void writeAnchoredRegistry(final int count) throws Exception {
|
||||
// AERE D-146 (2026-08-06): v2, proof-bound, bound at H, the height armAnchor() arms from. The
|
||||
// rows come from PqV2Fixture because a v2 claim must be signed by the validator whose address
|
||||
// is on the row, and the 0xA00+i addresses this used to spell have no key behind them.
|
||||
final KeccakDigest kd = new KeccakDigest(256);
|
||||
final StringBuilder manifest = new StringBuilder();
|
||||
manifest
|
||||
.append("{\"config\":{\"aereFalconRegistry\":{")
|
||||
.append(PqV2Fixture.manifestHeader(count, CHAIN_ID, H));
|
||||
for (int i = 0; i < count; i++) {
|
||||
final FalconPrivateKeyParameters priv = PqV2Fixture.privateKey(i);
|
||||
final byte[] anchoredRow = PqV2Fixture.anchorPreimageRow(i);
|
||||
kd.update(anchoredRow, 0, anchoredRow.length);
|
||||
manifest.append(',').append(PqV2Fixture.manifestEntry(i, count, CHAIN_ID, H));
|
||||
if (i == 0) {
|
||||
final Path key0 = tmp.resolve("falcon-key-0.properties");
|
||||
Files.writeString(
|
||||
key0,
|
||||
"index=0\n"
|
||||
+ "f="
|
||||
+ Bytes.wrap(priv.getSpolyf()).toHexString()
|
||||
+ "\n"
|
||||
+ "g="
|
||||
+ Bytes.wrap(priv.getG()).toHexString()
|
||||
+ "\n"
|
||||
+ "F="
|
||||
+ Bytes.wrap(priv.getSpolyF()).toHexString()
|
||||
+ "\n"
|
||||
+ "pk="
|
||||
+ Bytes.wrap(PqV2Fixture.publicKey(i)).toHexString()
|
||||
+ "\n");
|
||||
System.setProperty("aere.falcon.key", key0.toAbsolutePath().toString());
|
||||
}
|
||||
}
|
||||
manifest.append("}},\"alloc\":{\"0000000000000000000000000000000000000fa1\":{\"storage\":{\"0x")
|
||||
.append("0".repeat(64))
|
||||
.append("\":\"0x");
|
||||
final byte[] anchoredHash = new byte[32];
|
||||
kd.doFinal(anchoredHash, 0);
|
||||
manifest.append(Bytes.wrap(anchoredHash).toUnprefixedHexString()).append("\"}}}}");
|
||||
|
||||
final Path genesis = tmp.resolve("genesis-registry.json");
|
||||
Files.writeString(genesis, manifest.toString());
|
||||
System.setProperty("aere.falcon.genesis", genesis.toAbsolutePath().toString());
|
||||
}
|
||||
|
||||
private static void resetFalconSingleton() throws Exception {
|
||||
final Field f = FalconSealSupport.class.getDeclaredField("instance");
|
||||
f.setAccessible(true);
|
||||
f.set(null, null);
|
||||
}
|
||||
}
|
||||
@ -1,428 +0,0 @@
|
||||
/*
|
||||
* Copyright contributors to Besu / AERE Network.
|
||||
*
|
||||
* 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 static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.mockito.Mockito.withSettings;
|
||||
|
||||
import org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer;
|
||||
import org.hyperledger.besu.consensus.common.validator.ValidatorProvider;
|
||||
import org.hyperledger.besu.datatypes.Address;
|
||||
import org.hyperledger.besu.ethereum.ProtocolContext;
|
||||
import org.hyperledger.besu.ethereum.core.BlockHeader;
|
||||
import org.hyperledger.besu.ethereum.core.BlockHeaderTestFixture;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.OptionalInt;
|
||||
|
||||
import org.apache.tuweni.bytes.Bytes;
|
||||
import org.apache.tuweni.bytes.Bytes32;
|
||||
import org.bouncycastle.crypto.digests.KeccakDigest;
|
||||
import org.bouncycastle.pqc.crypto.falcon.FalconPrivateKeyParameters;
|
||||
import org.bouncycastle.pqc.crypto.falcon.FalconSigner;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.quality.Strictness;
|
||||
|
||||
/**
|
||||
* D-078. THE MEASUREMENT THAT DID NOT EXIST.
|
||||
*
|
||||
* <p>The registry entry reads: "if PQC were armed, an ordinary add-validator vote would stop the
|
||||
* chain: the Falcon blocking quorum follows the dynamic set and cannot be reached inside the vote
|
||||
* window", and it carried {@code verifica: NICIUNA} because "the direct measurement would require
|
||||
* ARMING PQC on a chain, which is exactly the thing that stops the chain".
|
||||
*
|
||||
* <p>That is true of a whole chain. It is NOT true of the decision that stops it. Every step from
|
||||
* "the validator set changed" to "no block can be proposed" is taken by three objects in this
|
||||
* module, each of which is a pure function of its inputs: {@link FalconSealSupport#attachmentArmed}
|
||||
* decides whether this node emits a Falcon seal at all, {@link PqSealCache} holds what was heard,
|
||||
* and {@link PqAnchorProducer#apply} decides whether this node may propose. This class drives those
|
||||
* three with a REAL address-bound genesis-anchored registry and REAL Falcon-512 keys, and asks the
|
||||
* question the registry says cannot be asked.
|
||||
*
|
||||
* <p>WHAT EACH TEST MEASURES, and why each of them can fail:
|
||||
*
|
||||
* <ol>
|
||||
* <li>{@link #baselineTheGateIsArmedWhileTheRegistryCoversTheSet()} - the negative control for
|
||||
* every other test here. If the gate were simply always off, or the registry never loaded,
|
||||
* the three tests below would "pass" for a reason that has nothing to do with D-078. This one
|
||||
* fails if the fixture is not genuinely armed.
|
||||
* <li>{@link #addingOneValidatorMustNotTurnSealAttachmentOff()} - D-078 itself, on the exact
|
||||
* stimulus in the title: one more validator in the set, with no Falcon key.
|
||||
* <li>{@link #aNodeStartedAboveTheAnchorHeightMustStillAttach()} - the SAME halt through a much
|
||||
* more ordinary door than a vote: a restart. Above the anchor height the only caller of
|
||||
* {@code observeValidators} has retired, so a node that starts there never observes a
|
||||
* validator set at all.
|
||||
* <li>{@link #theProposerRefusesWhenNothingWasAttachedAndProposesWhenSomethingWas()} - the causal
|
||||
* link, measured in both directions, so that "attachment off" to "chain stopped" is not an
|
||||
* assertion. Nothing heard: the proposer throws and cannot propose. Five real seals heard:
|
||||
* the proposer produces extraData carrying a five-seal certificate.
|
||||
* </ol>
|
||||
*
|
||||
* <p>NOT MEASURED here, deliberately, and named so it is not mistaken for covered: how many rounds a
|
||||
* live fleet takes to stop once every proposer refuses, and what a syncing node does meanwhile.
|
||||
* Those need a network, and the network run is separate evidence.
|
||||
*/
|
||||
public class D078ValidatorSetChangeTest {
|
||||
|
||||
/** Anchor activation height H used throughout. */
|
||||
private static final long H = 1_000L;
|
||||
|
||||
/** Seal-attachment height, comfortably below H. */
|
||||
private static final long ATTACH = 900L;
|
||||
|
||||
/** Height from which the staged threshold K is 5, i.e. the armed regime. */
|
||||
private static final long K_AT = H + 10L;
|
||||
|
||||
private static final int K = 5;
|
||||
|
||||
private static final int N = 7;
|
||||
|
||||
private static final long CHAIN_ID = 220_878L;
|
||||
|
||||
@TempDir private Path tmp;
|
||||
|
||||
private final List<Address> keyedValidators = new ArrayList<>();
|
||||
private final List<FalconPrivateKeyParameters> privateKeys = new ArrayList<>();
|
||||
private Address newcomer;
|
||||
private Path genesisPath;
|
||||
private Path key0Path;
|
||||
|
||||
@BeforeEach
|
||||
public void setUp() throws Exception {
|
||||
// The genesis-anchored path is only satisfied when keccak256(addr20 || pk, indices ascending)
|
||||
// equals the hash stored in the genesis alloc. The digest is accumulated here in lockstep with
|
||||
// the manifest text, so the fixture is anchored the same way a real genesis is.
|
||||
//
|
||||
// AERE D-146 (2026-08-06): v2, proof-bound, bound at H. The addresses come from PqV2Fixture and
|
||||
// are DERIVED from real secp256k1 keys, because a claim has to be signed by the validator whose
|
||||
// address is on the row and no key produces the 0xA00+i addresses this used to spell.
|
||||
final KeccakDigest kd = new KeccakDigest(256);
|
||||
final StringBuilder manifest = new StringBuilder();
|
||||
manifest
|
||||
.append("{\"config\":{\"aereFalconRegistry\":{")
|
||||
.append(PqV2Fixture.manifestHeader(N, CHAIN_ID, H));
|
||||
for (int i = 0; i < N; i++) {
|
||||
final FalconPrivateKeyParameters priv = PqV2Fixture.privateKey(i);
|
||||
privateKeys.add(priv);
|
||||
keyedValidators.add(PqV2Fixture.address(i));
|
||||
final byte[] anchoredRow = PqV2Fixture.anchorPreimageRow(i);
|
||||
kd.update(anchoredRow, 0, anchoredRow.length);
|
||||
manifest.append(',').append(PqV2Fixture.manifestEntry(i, N, CHAIN_ID, H));
|
||||
if (i == 0) {
|
||||
key0Path = tmp.resolve("falcon-key-0.properties");
|
||||
Files.writeString(
|
||||
key0Path,
|
||||
"index=0\n"
|
||||
+ "f="
|
||||
+ Bytes.wrap(priv.getSpolyf()).toHexString()
|
||||
+ "\n"
|
||||
+ "g="
|
||||
+ Bytes.wrap(priv.getG()).toHexString()
|
||||
+ "\n"
|
||||
+ "F="
|
||||
+ Bytes.wrap(priv.getSpolyF()).toHexString()
|
||||
+ "\n"
|
||||
+ "pk="
|
||||
+ Bytes.wrap(PqV2Fixture.publicKey(i)).toHexString()
|
||||
+ "\n");
|
||||
System.setProperty("aere.falcon.key", key0Path.toAbsolutePath().toString());
|
||||
}
|
||||
}
|
||||
manifest.append("}},\"alloc\":{\"0000000000000000000000000000000000000fa1\":{\"storage\":{\"0x")
|
||||
.append("0".repeat(64))
|
||||
.append("\":\"0x");
|
||||
final byte[] anchoredHash = new byte[32];
|
||||
kd.doFinal(anchoredHash, 0);
|
||||
manifest.append(Bytes.wrap(anchoredHash).toUnprefixedHexString()).append("\"}}}}");
|
||||
// The eighth validator: a perfectly ordinary node that an ordinary vote admits, and that has no
|
||||
// Falcon key because the manifest that is anchored on chain was written for seven. It is row N
|
||||
// of the same probe pool, so it is a REAL address with a REAL key behind it that simply was not
|
||||
// filed in the registry - which is the situation this test is about.
|
||||
newcomer = PqV2Fixture.address(N);
|
||||
|
||||
genesisPath = tmp.resolve("genesis-registry.json");
|
||||
Files.writeString(genesisPath, manifest.toString());
|
||||
System.setProperty("aere.falcon.genesis", genesisPath.toAbsolutePath().toString());
|
||||
System.setProperty("aere.falcon.attachBlock", Long.toString(ATTACH));
|
||||
|
||||
resetFalconSingleton();
|
||||
PqSealCache.instance().clear();
|
||||
PqAnchorProducer.useConfigForTesting(
|
||||
new PqAnchorConfig(CHAIN_ID, H, Map.of(H, 0, K_AT, K), OptionalInt.empty(), false));
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
public void tearDown() throws Exception {
|
||||
System.clearProperty("aere.falcon.genesis");
|
||||
System.clearProperty("aere.falcon.key");
|
||||
System.clearProperty("aere.falcon.attachBlock");
|
||||
resetFalconSingleton();
|
||||
PqSealCache.instance().clear();
|
||||
PqAnchorProducer.useConfigForTesting(null);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------------------
|
||||
// 1. Negative control for the fixture itself.
|
||||
// -----------------------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
public void baselineTheGateIsArmedWhileTheRegistryCoversTheSet() {
|
||||
final FalconSealSupport pqc = FalconSealSupport.instance();
|
||||
assertThat(pqc.genesisAnchored())
|
||||
.describedAs("the fixture must load a GENESIS-ANCHORED registry, or nothing below means anything")
|
||||
.isTrue();
|
||||
assertThat(pqc.addressBound()).isTrue();
|
||||
assertThat(pqc.registrySize()).isEqualTo(N);
|
||||
assertThat(pqc.signingEnabled()).isTrue();
|
||||
|
||||
pqc.observeValidators(H + 1L, keyedValidators);
|
||||
assertThat(pqc.attachmentArmed(H + 2L))
|
||||
.describedAs("with the registry covering all %d validators the gate must be ARMED", N)
|
||||
.isTrue();
|
||||
final Optional<FalconSeal> seal = pqc.sign(H + 2L, message(H + 1L));
|
||||
assertThat(seal).isPresent();
|
||||
assertThat(pqc.verify(0, message(H + 1L), seal.get().getSignature())).isTrue();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------------------
|
||||
// 2. D-078 on its own stimulus: one validator added.
|
||||
// -----------------------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
public void addingOneValidatorMustNotTurnSealAttachmentOff() {
|
||||
final FalconSealSupport pqc = FalconSealSupport.instance();
|
||||
|
||||
pqc.observeValidators(H + 1L, keyedValidators);
|
||||
assertThat(pqc.attachmentArmed(H + 2L))
|
||||
.describedAs("armed before the set changes")
|
||||
.isTrue();
|
||||
|
||||
final List<Address> afterVote = new ArrayList<>(keyedValidators);
|
||||
afterVote.add(newcomer);
|
||||
pqc.observeValidators(H + 2L, afterVote);
|
||||
|
||||
assertThat(pqc.attachmentArmed(H + 3L))
|
||||
.describedAs(
|
||||
"D-078: one ordinary add-validator vote must not switch Falcon seal ATTACHMENT off. "
|
||||
+ "It is a fleet-wide fact, so it turns off on EVERY node at the same height; with "
|
||||
+ "no node attaching, no proposer can gather K=%d seals and the chain stops with no "
|
||||
+ "way to carry the re-anchoring transaction that would repair it.",
|
||||
K)
|
||||
.isTrue();
|
||||
assertThat(pqc.sign(H + 3L, message(H + 2L)))
|
||||
.describedAs("and the seal must actually be produced, not merely permitted")
|
||||
.isPresent();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------------------
|
||||
// 3. The same halt through a restart, which needs no vote at all.
|
||||
// -----------------------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
public void aNodeStartedAboveTheAnchorHeightMustStillAttach() {
|
||||
// No observeValidators call at all. Above H the only caller of it, FalconSealValidationRule,
|
||||
// returns at its retirement gate before observing, so this is exactly the state of a node whose
|
||||
// chain head is already above H when the process starts.
|
||||
final FalconSealSupport pqc = FalconSealSupport.instance();
|
||||
assertThat(pqc.attachmentArmed(H + 50L))
|
||||
.describedAs(
|
||||
"a node that starts above the anchor height has observed no validator set, and "
|
||||
+ "\"I could not measure the set\" must not be answered with \"stop signing\": that "
|
||||
+ "answer is the halt. Restarting a node is an ordinary operation.")
|
||||
.isTrue();
|
||||
assertThat(pqc.sign(H + 50L, message(H + 49L))).isPresent();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------------------
|
||||
// 4. The causal link, measured in BOTH directions.
|
||||
// -----------------------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
public void theProposerRefusesWhenNothingWasAttachedAndProposesWhenSomethingWas() {
|
||||
final BlockHeader parent = new BlockHeaderTestFixture().number(K_AT + 20L).buildHeader();
|
||||
final ProtocolContext context = contextWith(keyedValidators);
|
||||
final BftExtraData base =
|
||||
new BftExtraData(
|
||||
Bytes32.ZERO,
|
||||
Collections.emptyList(),
|
||||
Optional.empty(),
|
||||
0,
|
||||
keyedValidators,
|
||||
Collections.emptyList());
|
||||
|
||||
// (a) nothing heard, because nothing was attached: the proposer cannot propose.
|
||||
assertThatThrownBy(() -> PqAnchorProducer.apply(base, parent, context))
|
||||
.isInstanceOf(PqAnchorNotReadyException.class);
|
||||
|
||||
// (b) five real Falcon seals heard: the same proposer, same inputs, produces a certificate.
|
||||
// Without this half, (a) would be satisfied by a producer that always refuses.
|
||||
final Bytes32 m =
|
||||
PqAnchor.commitMessage(CHAIN_ID, parent.getNumber(), parent.getHash().getBytes());
|
||||
final List<FalconSeal> heard = new ArrayList<>();
|
||||
for (int i = 0; i < K; i++) {
|
||||
heard.add(new FalconSeal(i, Bytes.wrap(falconSign(privateKeys.get(i), m))));
|
||||
}
|
||||
PqSealCache.instance().record(parent.getNumber(), parent.getHash(), heard);
|
||||
|
||||
final BftExtraData produced = PqAnchorProducer.apply(base, parent, context);
|
||||
assertThat(produced.getFalconSeals()).hasSize(K);
|
||||
assertThat(produced.getVanityData())
|
||||
.isEqualTo(
|
||||
PqAnchor.anchorDigest(
|
||||
CHAIN_ID,
|
||||
parent.getNumber(),
|
||||
parent.getHash().getBytes(),
|
||||
PqAnchor.sortedByIndex(heard)));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------------------
|
||||
// 4b. What an unkeyed validator actually costs, as a number rather than as a worry.
|
||||
// -----------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Removing coverage from the attachment gate stops the halt; it does not make adding an unkeyed
|
||||
* validator free. The price is the guaranteed number of anchored-key holders among a block's ECDSA
|
||||
* committers, and it is arithmetic, not opinion: a block needs {@code ceil(2N/3)} committers, and
|
||||
* the unluckiest committer set takes every unkeyed validator first.
|
||||
*
|
||||
* <p>The three rows below are the ones that decide the project's own arming order, so they are
|
||||
* measured here rather than reasoned about in a document:
|
||||
*
|
||||
* <pre>
|
||||
* N=7, keyed 7, quorum 5 -> 5 guaranteed K=5 is met, with EXACTLY zero margin
|
||||
* N=8, keyed 7, quorum 6 -> 5 guaranteed K=5 is still met, still zero margin
|
||||
* N=9, keyed 7, quorum 6 -> 4 guaranteed K=5 is NOT guaranteed any more
|
||||
* </pre>
|
||||
*
|
||||
* <p>Read against the standing rule "grow to N=9 BEFORE arming", that third row is the warning:
|
||||
* growing to nine while the anchored manifest still names seven is exactly the state in which a
|
||||
* proposer can legitimately fail to assemble a certificate. The manifest has to grow with the set.
|
||||
*/
|
||||
@Test
|
||||
public void theCostOfAnUnkeyedValidatorIsANumberAndTheNumberIsThis() {
|
||||
assertThat(FalconSealSupport.worstCaseKeyedSigners(7, 7))
|
||||
.describedAs("N=7 fully keyed: K=5 is met with zero margin")
|
||||
.isEqualTo(5);
|
||||
assertThat(FalconSealSupport.worstCaseKeyedSigners(8, 7))
|
||||
.describedAs("one validator added without re-anchoring: K=5 still met, still zero margin")
|
||||
.isEqualTo(5);
|
||||
assertThat(FalconSealSupport.worstCaseKeyedSigners(9, 7))
|
||||
.describedAs(
|
||||
"two added without re-anchoring: below K=5, so a proposer can legitimately fail. This "
|
||||
+ "is the row that constrains growing to N=9 before arming.")
|
||||
.isEqualTo(4);
|
||||
assertThat(FalconSealSupport.worstCaseKeyedSigners(7, 0)).isZero();
|
||||
assertThat(FalconSealSupport.worstCaseKeyedSigners(0, 0)).isZero();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------------------
|
||||
// 5. NEGATIVE CONTROL for this whole file: the gate must still refuse what it must refuse.
|
||||
// -----------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Every other test here asserts that the gate says YES. Replace {@code attachmentArmed} with
|
||||
* {@code return true} and all of them still pass, which would make this file a proof that cannot
|
||||
* fail. These four assertions are what makes that substitution impossible: each names a condition
|
||||
* the D-078 repair deliberately did NOT touch.
|
||||
*
|
||||
* @throws Exception if the fixture cannot be rebuilt
|
||||
*/
|
||||
@Test
|
||||
public void theGateStillRefusesEverythingItMustStillRefuse() throws Exception {
|
||||
// (1) below the configured attachment height.
|
||||
assertThat(FalconSealSupport.instance().attachmentArmed(ATTACH - 1L))
|
||||
.describedAs("below the attachment height nothing may be attached")
|
||||
.isFalse();
|
||||
|
||||
// (2) no attachment height configured at all, which is the default and the state of chain 2800.
|
||||
System.clearProperty("aere.falcon.attachBlock");
|
||||
resetFalconSingleton();
|
||||
assertThat(FalconSealSupport.instance().attachmentArmed(H + 5L))
|
||||
.describedAs("with aere.falcon.attachBlock unset a node holding a key attaches nothing")
|
||||
.isFalse();
|
||||
|
||||
// (3) attachment height reached, but no anchored registry to be checked against.
|
||||
System.setProperty("aere.falcon.attachBlock", Long.toString(ATTACH));
|
||||
System.clearProperty("aere.falcon.genesis");
|
||||
resetFalconSingleton();
|
||||
assertThat(FalconSealSupport.instance().attachmentArmed(H + 5L))
|
||||
.describedAs("a seal is never emitted against a registry that cannot be checked")
|
||||
.isFalse();
|
||||
|
||||
// (4) anchored, address-bound registry, but it does not bind THIS node's index. The seal would
|
||||
// be unattributable, so the seals rule would refuse the whole header carrying it.
|
||||
System.setProperty("aere.falcon.genesis", genesisPath.toAbsolutePath().toString());
|
||||
final Path strayKey = tmp.resolve("falcon-key-stray.properties");
|
||||
Files.writeString(strayKey, Files.readString(key0Path).replace("index=0", "index=42"));
|
||||
System.setProperty("aere.falcon.key", strayKey.toAbsolutePath().toString());
|
||||
resetFalconSingleton();
|
||||
final FalconSealSupport stray = FalconSealSupport.instance();
|
||||
assertThat(stray.genesisAnchored())
|
||||
.describedAs("the registry must still load, or (4) would pass for the wrong reason")
|
||||
.isTrue();
|
||||
assertThat(stray.attachmentArmed(H + 5L))
|
||||
.describedAs("an index the anchored registry does not bind must not attach")
|
||||
.isFalse();
|
||||
assertThat(stray.sign(H + 5L, message(H + 4L))).isEmpty();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------------------
|
||||
// Helpers.
|
||||
// -----------------------------------------------------------------------------------------
|
||||
|
||||
private static Bytes32 message(final long blockNumber) {
|
||||
return PqAnchor.commitMessage(CHAIN_ID, blockNumber, Bytes32.leftPad(Bytes.of(1)));
|
||||
}
|
||||
|
||||
private static byte[] falconSign(final FalconPrivateKeyParameters key, final Bytes32 m) {
|
||||
final FalconSigner signer = new FalconSigner();
|
||||
signer.init(true, key);
|
||||
return signer.generateSignature(m.toArray());
|
||||
}
|
||||
|
||||
private static ProtocolContext contextWith(final Collection<Address> validators) {
|
||||
final ValidatorProvider validatorProvider =
|
||||
mock(ValidatorProvider.class, withSettings().strictness(Strictness.LENIENT));
|
||||
when(validatorProvider.getValidatorsForBlock(any())).thenReturn(validators);
|
||||
when(validatorProvider.getValidatorsAfterBlock(any())).thenReturn(validators);
|
||||
final BftContext bftContext =
|
||||
mock(BftContext.class, withSettings().strictness(Strictness.LENIENT));
|
||||
when(bftContext.getValidatorProvider()).thenReturn(validatorProvider);
|
||||
when(bftContext.as(any())).thenReturn(bftContext);
|
||||
return new ProtocolContext.Builder().withConsensusContext(bftContext).build();
|
||||
}
|
||||
|
||||
private static void resetFalconSingleton() throws Exception {
|
||||
final Field f = FalconSealSupport.class.getDeclaredField("instance");
|
||||
f.setAccessible(true);
|
||||
f.set(null, null);
|
||||
}
|
||||
}
|
||||
@ -1,368 +0,0 @@
|
||||
/*
|
||||
* Copyright contributors to Besu / AERE Network.
|
||||
*
|
||||
* 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 java.lang.reflect.Field;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import org.apache.tuweni.bytes.Bytes;
|
||||
import org.bouncycastle.crypto.digests.KeccakDigest;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
/**
|
||||
* D-079. THE MEASUREMENT THAT DID NOT EXIST.
|
||||
*
|
||||
* <p>The registry entry reads: "a malformed forkBlock falls OPEN, with only a log line, and arming
|
||||
* it at or before the anchor observation height passes undetected", and it carried {@code verifica:
|
||||
* NICIUNA} since 18 July. This file is the command that can fail.
|
||||
*
|
||||
* <p>Both halves of the finding are about the SAME shape of defect, the one the Holesky Pectra
|
||||
* incident of February 2025 made expensive for everybody: a fork-activation parameter that is wrong
|
||||
* or absent does not stop the node, it changes what the node silently believes. Half one is the
|
||||
* value itself. Half two is the ORDER between that value and the height at which the registry the
|
||||
* value depends on becomes active.
|
||||
*
|
||||
* <p>WHAT EACH TEST MEASURES, and how each can fail:
|
||||
*
|
||||
* <ol>
|
||||
* <li>{@link #controlAWellFormedLateAnchorConfigurationStarts()} - the fixture's own negative
|
||||
* control. If the late-anchor manifest did not load, or the singleton were not really being
|
||||
* rebuilt, every refusal below would be a refusal for the wrong reason.
|
||||
* <li>{@link #aMalformedForkBlockRefusesToStart()} and {@link #aNegativeForkBlockRefusesToStart()}
|
||||
* - the config-time half of the finding, at construction.
|
||||
* <li>{@link #theForkBlockIsResolvedOnceAndCannotBeReopenedAfterStartup()} - the RESIDUAL half
|
||||
* one. The startup guard only ever looked at the property once, but {@code forkBlock()}
|
||||
* re-read the property on every call and fell back to "never blocking" with a log line on
|
||||
* anything it could not parse. A guard that validates a value it does not then own is not a
|
||||
* guard; this test drives that exact gap.
|
||||
* <li>{@link #blockingOverAPendingAnchorWithNoDeclaredObservationHeightRefuses()} - half two. A
|
||||
* blocking height is stated over a registry that is not active yet and whose activation
|
||||
* height is nowhere stated, so nothing in the process can compare the two.
|
||||
* <li>{@link #anAttachHeightBeforeTheObservationHeightRefuses()} and {@link
|
||||
* #aForkHeightAtTheObservationHeightRefuses()} - half two on its own stimulus: the ordering
|
||||
* is wrong and the node starts anyway.
|
||||
* <li>{@link #aMalformedObservationHeightRefuses()} and {@link
|
||||
* #aNegativeObservationHeightRefuses()} - the new value must fail closed like every other
|
||||
* {@code aere.falcon.*} value. A half-fail-closed property set is worse than either extreme.
|
||||
* <li>{@link #aGenesisAnchoredRegistryNeedsNoObservationHeight()} and {@link
|
||||
* #anObservationHeightWithoutBlockingIsHarmless()} - the scope controls. A guard that refused
|
||||
* every blocking configuration would pass every test above and be useless.
|
||||
* </ol>
|
||||
*
|
||||
* <p>NOT MEASURED here, deliberately, and named so it is not mistaken for covered: whether a real
|
||||
* Besu node process exits with a non-zero status when this exception is thrown. This class measures
|
||||
* the decision, not the process. The exception is thrown from the constructor, on the same path as
|
||||
* the guards that already abort, and nothing in this tree catches {@code
|
||||
* FalconSealSupport.ActivationConfigException}.
|
||||
*/
|
||||
public class D079ForkArmingTest {
|
||||
|
||||
/** Fleet size; nine, because the blocking guard refuses to arm below nine. */
|
||||
private static final int N = 9;
|
||||
|
||||
/** Height at which the on-chain late-anchor registry contract is expected to be observed. */
|
||||
private static final long OBSERVE = 5_000L;
|
||||
|
||||
/** Seal-attachment height: at or after OBSERVE, so a seal can actually be emitted. */
|
||||
private static final long ATTACH = 6_000L;
|
||||
|
||||
/** Blocking height: at least minAttachLead (256) after ATTACH. */
|
||||
private static final long FORK = 7_000L;
|
||||
|
||||
private static final String ANCHOR_ADDRESS = "0x0000000000000000000000000000000000000fa1";
|
||||
|
||||
/**
|
||||
* AERE D-146: the chain this fixture's registries are BOUND to. Every proof commits to it, so it
|
||||
* has to be stated rather than defaulted.
|
||||
*/
|
||||
private static final long CHAIN_ID = 2_800L;
|
||||
|
||||
@TempDir private Path tmp;
|
||||
|
||||
private Path manifestPath;
|
||||
private Path genesisPath;
|
||||
|
||||
@BeforeEach
|
||||
public void setUp() throws Exception {
|
||||
// AERE D-146 (2026-08-06): both registries below are v2 and PROOF-BOUND, bound at FORK, the
|
||||
// height this fixture arms from. They used to carry addresses spelled 0xB00+i, which no
|
||||
// secp256k1 key can sign for, so this whole fixture became unstartable the moment
|
||||
// AERE-PQC-REG-ARM-02 was wired into the constructor.
|
||||
|
||||
// LATE-ANCHOR manifest: the registry is PENDING until the anchor contract is observed on chain.
|
||||
final StringBuilder late = new StringBuilder("{");
|
||||
late.append(PqV2Fixture.manifestHeader(N, CHAIN_ID, FORK));
|
||||
for (int i = 0; i < N; i++) {
|
||||
late.append(',').append(PqV2Fixture.manifestEntry(i, N, CHAIN_ID, FORK));
|
||||
}
|
||||
late.append("}");
|
||||
manifestPath = tmp.resolve("falcon-late-manifest.json");
|
||||
Files.writeString(manifestPath, late.toString());
|
||||
|
||||
// GENESIS-ANCHORED manifest: the registry is ACTIVE from block 0, so no observation height can
|
||||
// exist and none may be demanded. Built exactly the way a real genesis is, hash included.
|
||||
final KeccakDigest kd = new KeccakDigest(256);
|
||||
final StringBuilder gen = new StringBuilder("{\"config\":{\"aereFalconRegistry\":{");
|
||||
gen.append(PqV2Fixture.manifestHeader(N, CHAIN_ID, FORK));
|
||||
for (int i = 0; i < N; i++) {
|
||||
final byte[] anchoredRow = PqV2Fixture.anchorPreimageRow(i);
|
||||
kd.update(anchoredRow, 0, anchoredRow.length);
|
||||
gen.append(',').append(PqV2Fixture.manifestEntry(i, N, CHAIN_ID, FORK));
|
||||
}
|
||||
final byte[] anchoredHash = new byte[32];
|
||||
kd.doFinal(anchoredHash, 0);
|
||||
gen.append("}},\"alloc\":{\"0000000000000000000000000000000000000fa1\":{\"storage\":{\"0x")
|
||||
.append("0".repeat(64))
|
||||
.append("\":\"0x")
|
||||
.append(Bytes.wrap(anchoredHash).toUnprefixedHexString())
|
||||
.append("\"}}}}");
|
||||
genesisPath = tmp.resolve("genesis-registry.json");
|
||||
Files.writeString(genesisPath, gen.toString());
|
||||
|
||||
System.setProperty("aere.falcon.validatorCount", Integer.toString(N));
|
||||
resetFalconSingleton();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
public void tearDown() throws Exception {
|
||||
for (final String p :
|
||||
new String[] {
|
||||
"aere.falcon.manifest",
|
||||
"aere.falcon.genesis",
|
||||
"aere.falcon.anchor.address",
|
||||
"aere.falcon.anchor.block",
|
||||
"aere.falcon.attachBlock",
|
||||
"aere.falcon.forkBlock",
|
||||
"aere.falcon.validatorCount"
|
||||
}) {
|
||||
System.clearProperty(p);
|
||||
}
|
||||
resetFalconSingleton();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// 1. The fixture's own control.
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
public void controlAWellFormedLateAnchorConfigurationStarts() {
|
||||
lateAnchor();
|
||||
System.setProperty("aere.falcon.anchor.block", Long.toString(OBSERVE));
|
||||
System.setProperty("aere.falcon.attachBlock", Long.toString(ATTACH));
|
||||
System.setProperty("aere.falcon.forkBlock", Long.toString(FORK));
|
||||
|
||||
final FalconSealSupport pqc = FalconSealSupport.instance();
|
||||
assertThat(pqc.lateAnchorPending())
|
||||
.describedAs(
|
||||
"the late-anchor manifest must load and stay PENDING, or every refusal below is a "
|
||||
+ "refusal about a registry that was never there")
|
||||
.isTrue();
|
||||
assertThat(pqc.forkBlock()).isEqualTo(FORK);
|
||||
assertThat(pqc.attachBlock()).isEqualTo(ATTACH);
|
||||
assertThat(pqc.forkBlock())
|
||||
.describedAs(
|
||||
"the ordering the guard exists to enforce, stated as a property: blocking arms strictly "
|
||||
+ "AFTER the height at which the registry it depends on can become active")
|
||||
.isGreaterThan(OBSERVE);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// 2-3. Half one at config time.
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
public void aMalformedForkBlockRefusesToStart() {
|
||||
lateAnchor();
|
||||
System.setProperty("aere.falcon.anchor.block", Long.toString(OBSERVE));
|
||||
System.setProperty("aere.falcon.attachBlock", Long.toString(ATTACH));
|
||||
// The exact typo shape a human makes when copying a height out of a document.
|
||||
System.setProperty("aere.falcon.forkBlock", "9_189_161");
|
||||
|
||||
assertThatThrownBy(FalconSealSupport::instance)
|
||||
.describedAs(
|
||||
"a malformed blocking height must ABORT, never degrade to never-blocking with a log line")
|
||||
.isInstanceOf(FalconSealSupport.ActivationConfigException.class)
|
||||
.hasMessageContaining("MALFORMED");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aNegativeForkBlockRefusesToStart() {
|
||||
lateAnchor();
|
||||
System.setProperty("aere.falcon.anchor.block", Long.toString(OBSERVE));
|
||||
System.setProperty("aere.falcon.attachBlock", Long.toString(ATTACH));
|
||||
System.setProperty("aere.falcon.forkBlock", "-1");
|
||||
|
||||
assertThatThrownBy(FalconSealSupport::instance)
|
||||
.isInstanceOf(FalconSealSupport.ActivationConfigException.class)
|
||||
.hasMessageContaining("negative");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// 4. Half one where it actually survived: the value was validated but never OWNED.
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
public void theForkBlockIsResolvedOnceAndCannotBeReopenedAfterStartup() {
|
||||
lateAnchor();
|
||||
System.setProperty("aere.falcon.anchor.block", Long.toString(OBSERVE));
|
||||
System.setProperty("aere.falcon.attachBlock", Long.toString(ATTACH));
|
||||
System.setProperty("aere.falcon.forkBlock", Long.toString(FORK));
|
||||
|
||||
final FalconSealSupport pqc = FalconSealSupport.instance();
|
||||
assertThat(pqc.forkBlock()).isEqualTo(FORK);
|
||||
|
||||
// The startup guard has already run and passed. Nothing will run it again. If the accessor
|
||||
// re-reads the property, then the ONE decision the whole PQC layer is gated on is a value that
|
||||
// can still turn into "never blocking" at any moment, for any reason that leaves the property
|
||||
// unparseable, and the only trace is one WARN line per call.
|
||||
System.setProperty("aere.falcon.forkBlock", "not-a-number");
|
||||
assertThat(pqc.forkBlock())
|
||||
.describedAs(
|
||||
"the blocking height must be resolved ONCE, at the boundary, and owned thereafter. A "
|
||||
+ "value that is validated at startup and re-parsed on every use is not validated.")
|
||||
.isEqualTo(FORK);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// 5-7. Half two: the ORDER between the blocking height and the anchor observation height.
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
public void blockingOverAPendingAnchorWithNoDeclaredObservationHeightRefuses() {
|
||||
lateAnchor();
|
||||
System.setProperty("aere.falcon.attachBlock", Long.toString(ATTACH));
|
||||
System.setProperty("aere.falcon.forkBlock", Long.toString(FORK));
|
||||
// aere.falcon.anchor.block deliberately NOT set.
|
||||
|
||||
assertThatThrownBy(FalconSealSupport::instance)
|
||||
.describedAs(
|
||||
"with the registry still PENDING and no stated activation height, nothing in this "
|
||||
+ "process can compare the blocking height against the height at which the registry "
|
||||
+ "becomes usable, so the ordering error the finding names cannot be detected at all")
|
||||
.isInstanceOf(FalconSealSupport.ActivationConfigException.class)
|
||||
.hasMessageContaining("AERE-PQC-CFG-UNSAFE-06");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void anAttachHeightBeforeTheObservationHeightRefuses() {
|
||||
lateAnchor();
|
||||
System.setProperty("aere.falcon.anchor.block", Long.toString(ATTACH + 1L));
|
||||
System.setProperty("aere.falcon.attachBlock", Long.toString(ATTACH));
|
||||
System.setProperty("aere.falcon.forkBlock", Long.toString(FORK));
|
||||
|
||||
assertThatThrownBy(FalconSealSupport::instance)
|
||||
.describedAs(
|
||||
"attachment before the registry can be active emits nothing, so the log-only soak "
|
||||
+ "window measures nothing and the blocking height arrives over a registry no node "
|
||||
+ "has ever produced a seal against")
|
||||
.isInstanceOf(FalconSealSupport.ActivationConfigException.class)
|
||||
.hasMessageContaining("AERE-PQC-CFG-UNSAFE-07");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aForkHeightAtTheObservationHeightRefuses() {
|
||||
lateAnchor();
|
||||
System.setProperty("aere.falcon.anchor.block", Long.toString(FORK));
|
||||
System.setProperty("aere.falcon.attachBlock", Long.toString(ATTACH));
|
||||
System.setProperty("aere.falcon.forkBlock", Long.toString(FORK));
|
||||
|
||||
assertThatThrownBy(FalconSealSupport::instance)
|
||||
.describedAs("the literal stimulus in the finding: armed AT the anchor observation height")
|
||||
.isInstanceOf(FalconSealSupport.ActivationConfigException.class)
|
||||
.hasMessageContaining("AERE-PQC-CFG-UNSAFE-07");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// 8-9. The new value must fail closed like every other one.
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
public void aMalformedObservationHeightRefuses() {
|
||||
lateAnchor();
|
||||
System.setProperty("aere.falcon.anchor.block", "1e3");
|
||||
System.setProperty("aere.falcon.attachBlock", Long.toString(ATTACH));
|
||||
System.setProperty("aere.falcon.forkBlock", Long.toString(FORK));
|
||||
|
||||
assertThatThrownBy(FalconSealSupport::instance)
|
||||
.isInstanceOf(FalconSealSupport.ActivationConfigException.class)
|
||||
.hasMessageContaining("AERE-PQC-CFG-SYNTAX-09");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aNegativeObservationHeightRefuses() {
|
||||
lateAnchor();
|
||||
System.setProperty("aere.falcon.anchor.block", "-5");
|
||||
System.setProperty("aere.falcon.attachBlock", Long.toString(ATTACH));
|
||||
System.setProperty("aere.falcon.forkBlock", Long.toString(FORK));
|
||||
|
||||
assertThatThrownBy(FalconSealSupport::instance)
|
||||
.isInstanceOf(FalconSealSupport.ActivationConfigException.class)
|
||||
.hasMessageContaining("AERE-PQC-CFG-SYNTAX-10");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// 10-11. Scope controls. A guard that refuses everything is not a guard.
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
public void aGenesisAnchoredRegistryNeedsNoObservationHeight() {
|
||||
System.setProperty("aere.falcon.genesis", genesisPath.toAbsolutePath().toString());
|
||||
System.setProperty("aere.falcon.attachBlock", Long.toString(ATTACH));
|
||||
System.setProperty("aere.falcon.forkBlock", Long.toString(FORK));
|
||||
// aere.falcon.anchor.block deliberately NOT set: a genesis-anchored registry is active from
|
||||
// block 0, so there IS no observation height and demanding one would break the whole
|
||||
// genesis-anchored deployment path.
|
||||
|
||||
final FalconSealSupport pqc = FalconSealSupport.instance();
|
||||
assertThat(pqc.genesisAnchored()).isTrue();
|
||||
assertThat(pqc.addressBound()).isTrue();
|
||||
assertThat(pqc.forkBlock()).isEqualTo(FORK);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void anObservationHeightWithoutBlockingIsHarmless() {
|
||||
lateAnchor();
|
||||
System.setProperty("aere.falcon.anchor.block", Long.toString(OBSERVE));
|
||||
// No forkBlock, no attachBlock: the log-only baseline every node on chain 2800 runs today.
|
||||
|
||||
final FalconSealSupport pqc = FalconSealSupport.instance();
|
||||
assertThat(pqc.forkBlock()).isEqualTo(Long.MAX_VALUE);
|
||||
assertThat(pqc.attachBlock()).isEqualTo(Long.MAX_VALUE);
|
||||
assertThat(pqc.lateAnchorPending()).isTrue();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// Helpers.
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
private void lateAnchor() {
|
||||
System.setProperty("aere.falcon.manifest", manifestPath.toAbsolutePath().toString());
|
||||
System.setProperty("aere.falcon.anchor.address", ANCHOR_ADDRESS);
|
||||
}
|
||||
|
||||
private static void resetFalconSingleton() throws Exception {
|
||||
final Field f = FalconSealSupport.class.getDeclaredField("instance");
|
||||
f.setAccessible(true);
|
||||
f.set(null, null);
|
||||
}
|
||||
}
|
||||
@ -1,484 +0,0 @@
|
||||
/*
|
||||
* Copyright contributors to Besu / AERE Network.
|
||||
*
|
||||
* 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 java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
/**
|
||||
* D-081: the Falcon signer registry has no usable rotation and no usable revocation.
|
||||
*
|
||||
* <p>WHAT IS MEASURED HERE, and why it is measured against the real classes rather than described.
|
||||
* {@code config.pqRegistryHash} is a SCHEDULE of {block, hash} entries, and the A8 dossier states
|
||||
* that "a later entry expresses a key rotation". This file asks whether that sentence survives
|
||||
* contact with the code that enforces it.
|
||||
*
|
||||
* <p>The enforcement side is {@link PqRegistryHash#matchesAt} and, on the block path, {@code
|
||||
* FalconSealSupport.registryBindingSatisfiedAt(height)}, which calls it. Both take exactly ONE
|
||||
* loaded registry, and the node loads exactly one file ({@code aere.falcon.registry}). The entry the
|
||||
* schedule makes active at a height decides which hash is required THERE. So after one rotation at
|
||||
* H2 there are two intervals with two different required hashes, and one file can satisfy at most
|
||||
* one of them.
|
||||
*
|
||||
* <p>The consequence is not cosmetic and it is not confined to the rotation moment. {@code
|
||||
* PqRegistryBindingRule} is a DETACHED rule, so it runs on the header-download path, and {@code
|
||||
* PqAnchorSyncModeGuard} refuses to start an armed node in anything but FULL sync. A node acquiring
|
||||
* history therefore validates every height, including the interval before the rotation. Holding the
|
||||
* post-rotation registry it is refused there; holding the pre-rotation registry it is refused at the
|
||||
* head. There is no third choice. ONE rotation makes the chain permanently unjoinable.
|
||||
*
|
||||
* <p>This is the lesson Cosmos ADR-016 writes down explicitly: a rotation scheme has to keep the
|
||||
* MAPPING FROM HEIGHT TO KEY SET, not only the current key set, or blocks signed under the old set
|
||||
* stop being verifiable. Cosmos may bound that history by the unbonding period. We may not: chain
|
||||
* 2800 has no unbonding period and a node syncing from genesis must verify every block that was ever
|
||||
* produced, so every entry ever scheduled has to stay loadable forever.
|
||||
*
|
||||
* <p>{@code rotationDoesNotBrickHistory} and {@code revocationDoesNotBrickHistory} are the
|
||||
* measurement. They FAIL while the defect is present and pass only when a node can be configured to
|
||||
* satisfy the binding at EVERY scheduled height at once. The other tests are controls: they assert
|
||||
* that the schedule really does express rotation and really does refuse a malformed one, so a
|
||||
* failure of the two measurements cannot be blamed on the fixture.
|
||||
*/
|
||||
public class D081RegistryRotationTest {
|
||||
|
||||
private static final long CHAIN_ID = 2800L;
|
||||
|
||||
/** First binding height: the height the post-quantum registry is first enforced from. */
|
||||
private static final long H1 = 12_000_000L;
|
||||
|
||||
/** Rotation height: from here the chain requires the SECOND registry. */
|
||||
private static final long H2 = 12_100_000L;
|
||||
|
||||
/** Falcon-512 public key length as this registry format stores it (bare h polynomial). */
|
||||
private static final int PK_LENGTH = 896;
|
||||
|
||||
/** The seven validators of chain 2800. */
|
||||
private static final int N = 7;
|
||||
|
||||
/**
|
||||
* One node configuration, expressed as the only question the consensus path ever asks it: does
|
||||
* the registry material this node holds satisfy the binding the chain requires at this height?
|
||||
*
|
||||
* <p>It is an interface and not a Registry so that the measurement can be stated once and asked of
|
||||
* every configuration a node can actually be put into. Today there is exactly one shape of answer,
|
||||
* {@link #single}, because a node loads one file. A repair that lets a node hold the whole
|
||||
* scheduled history adds a second shape here and the assertion below stops failing. Nothing in the
|
||||
* assertion has to change, which is the point: the property is fixed, the capability is what moves.
|
||||
*/
|
||||
private interface NodeConfiguration {
|
||||
boolean satisfiesAt(long height);
|
||||
|
||||
String describe();
|
||||
}
|
||||
|
||||
private static NodeConfiguration single(
|
||||
final String name, final PqRegistryHash.Schedule schedule, final PqRegistryHash.Registry r) {
|
||||
return new NodeConfiguration() {
|
||||
@Override
|
||||
public boolean satisfiesAt(final long height) {
|
||||
return PqRegistryHash.matchesAt(schedule, r, height, CHAIN_ID);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String describe() {
|
||||
return "node holding only registry " + name;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Fixture. Two registries that differ in exactly one row, which is what both a rotation and a
|
||||
// revocation look like on the wire: index 3 stops being the key it was.
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
private static byte[] deterministicKey(final int index, final int generation) {
|
||||
final byte[] pk = new byte[PK_LENGTH];
|
||||
for (int i = 0; i < pk.length; i++) {
|
||||
pk[i] = (byte) ((i * 31) + (index * 7) + (generation * 101));
|
||||
}
|
||||
return pk;
|
||||
}
|
||||
|
||||
private static byte[] address(final int index) {
|
||||
final byte[] a = new byte[20];
|
||||
for (int i = 0; i < a.length; i++) {
|
||||
a[i] = (byte) ((index * 17) + i);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
private static String hex(final byte[] b) {
|
||||
final StringBuilder sb = new StringBuilder(b.length * 2);
|
||||
for (final byte x : b) {
|
||||
sb.append(String.format("%02x", x));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* A seven-row address-bound registry. {@code rotatedIndex} is the row whose key belongs to
|
||||
* generation 2; every other row is generation 1. Passing -1 gives the untouched registry.
|
||||
*/
|
||||
private static Path writeRegistry(final Path dir, final String name, final int rotatedIndex)
|
||||
throws IOException {
|
||||
final StringBuilder sb = new StringBuilder();
|
||||
sb.append("count=").append(N).append('\n');
|
||||
for (int i = 0; i < N; i++) {
|
||||
sb.append(i).append('=').append(hex(deterministicKey(i, i == rotatedIndex ? 2 : 1))).append('\n');
|
||||
sb.append(i).append(".addr=").append(hex(address(i))).append('\n');
|
||||
}
|
||||
final Path p = dir.resolve(name);
|
||||
Files.write(p, sb.toString().getBytes(StandardCharsets.UTF_8));
|
||||
return p;
|
||||
}
|
||||
|
||||
private static PqRegistryHash.Schedule scheduleOf(final String hashAtH1, final String hashAtH2) {
|
||||
final String json =
|
||||
"[{\"block\":"
|
||||
+ H1
|
||||
+ ",\"hash\":\"0x"
|
||||
+ hashAtH1
|
||||
+ "\"},{\"block\":"
|
||||
+ H2
|
||||
+ ",\"hash\":\"0x"
|
||||
+ hashAtH2
|
||||
+ "\"}]";
|
||||
final JsonNode node;
|
||||
try {
|
||||
node = new ObjectMapper().readTree(json);
|
||||
} catch (final IOException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
return PqRegistryHash.parseSchedule(node, "D-081 fixture");
|
||||
}
|
||||
|
||||
/** Every height at which the binding is enforced and could differ across the rotation. */
|
||||
private static List<Long> enforcedHeights() {
|
||||
final List<Long> heights = new ArrayList<>();
|
||||
heights.add(H1);
|
||||
heights.add(H1 + 1);
|
||||
heights.add(H2 - 1);
|
||||
heights.add(H2);
|
||||
heights.add(H2 + 1);
|
||||
return heights;
|
||||
}
|
||||
|
||||
/**
|
||||
* The configurations a node can ACTUALLY be put into with the code as it stands. A repair that
|
||||
* gives a node the whole scheduled history appends its configuration here; nothing else changes.
|
||||
*/
|
||||
private static List<NodeConfiguration> availableConfigurations(
|
||||
final PqRegistryHash.Schedule schedule,
|
||||
final PqRegistryHash.Registry before,
|
||||
final PqRegistryHash.Registry after) {
|
||||
final List<NodeConfiguration> all = new ArrayList<>();
|
||||
all.add(single("BEFORE", schedule, before));
|
||||
all.add(single("AFTER", schedule, after));
|
||||
all.add(wholeHistory(schedule, before, after));
|
||||
return all;
|
||||
}
|
||||
|
||||
/**
|
||||
* D-081 repair: the node holds the WHOLE scheduled history and resolves by height. This
|
||||
* configuration did not exist before the repair, which is why the assertion below could not be
|
||||
* satisfied by any node at all.
|
||||
*/
|
||||
private static NodeConfiguration wholeHistory(
|
||||
final PqRegistryHash.Schedule schedule,
|
||||
final PqRegistryHash.Registry before,
|
||||
final PqRegistryHash.Registry after) {
|
||||
final List<PqRegistryHash.Registry> held = new ArrayList<>();
|
||||
held.add(before);
|
||||
held.add(after);
|
||||
final PqRegistryHash.RegistrySet set = PqRegistryHash.buildSet(schedule, held, CHAIN_ID);
|
||||
return new NodeConfiguration() {
|
||||
@Override
|
||||
public boolean satisfiesAt(final long height) {
|
||||
return PqRegistryHash.matchesAt(schedule, set, height, CHAIN_ID);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String describe() {
|
||||
return "node holding the whole scheduled history (" + set + ")";
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Controls. If these fail, the fixture is wrong and the measurements below mean nothing.
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
public void controlTheScheduleReallyDoesExpressARotation(@TempDir final Path dir)
|
||||
throws IOException {
|
||||
final PqRegistryHash.Registry before =
|
||||
PqRegistryHash.loadPropertiesRegistry(writeRegistry(dir, "before.properties", -1));
|
||||
final PqRegistryHash.Registry after =
|
||||
PqRegistryHash.loadPropertiesRegistry(writeRegistry(dir, "after.properties", 3));
|
||||
|
||||
final String hashBefore = PqRegistryHash.hashV1(before, CHAIN_ID);
|
||||
final String hashAfter = PqRegistryHash.hashV1(after, CHAIN_ID);
|
||||
assertThat(hashBefore).isNotEqualTo(hashAfter);
|
||||
|
||||
final PqRegistryHash.Schedule schedule = scheduleOf(hashBefore, hashAfter);
|
||||
assertThat(schedule.enforced()).isTrue();
|
||||
assertThat(schedule.entries()).hasSize(2);
|
||||
|
||||
// Below the first entry nothing is bound: the 11.8 million existing blocks stay untouched.
|
||||
assertThat(PqRegistryHash.requiredHashAt(schedule, H1 - 1)).isEmpty();
|
||||
assertThat(PqRegistryHash.requiredHashAt(schedule, H1).orElseThrow().hash()).isEqualTo(hashBefore);
|
||||
assertThat(PqRegistryHash.requiredHashAt(schedule, H2 - 1).orElseThrow().hash())
|
||||
.isEqualTo(hashBefore);
|
||||
assertThat(PqRegistryHash.requiredHashAt(schedule, H2).orElseThrow().hash()).isEqualTo(hashAfter);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void controlAMalformedScheduleIsRefused(@TempDir final Path dir) throws IOException {
|
||||
final PqRegistryHash.Registry before =
|
||||
PqRegistryHash.loadPropertiesRegistry(writeRegistry(dir, "before.properties", -1));
|
||||
final String h = PqRegistryHash.hashV1(before, CHAIN_ID);
|
||||
final String json =
|
||||
"[{\"block\":" + H2 + ",\"hash\":\"0x" + h + "\"},{\"block\":" + H1 + ",\"hash\":\"0x" + h + "\"}]";
|
||||
final JsonNode node = new ObjectMapper().readTree(json);
|
||||
assertThatThrownBy(() -> PqRegistryHash.parseSchedule(node, "D-081 fixture"))
|
||||
.isInstanceOf(PqRegistryHash.RegistryConfigException.class)
|
||||
.hasMessageContaining("STRICTLY INCREASING");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// THE MEASUREMENT.
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
public void rotationDoesNotBrickHistory(@TempDir final Path dir) throws IOException {
|
||||
final PqRegistryHash.Registry before =
|
||||
PqRegistryHash.loadPropertiesRegistry(writeRegistry(dir, "before.properties", -1));
|
||||
final PqRegistryHash.Registry after =
|
||||
PqRegistryHash.loadPropertiesRegistry(writeRegistry(dir, "after.properties", 3));
|
||||
final PqRegistryHash.Schedule schedule =
|
||||
scheduleOf(
|
||||
PqRegistryHash.hashV1(before, CHAIN_ID), PqRegistryHash.hashV1(after, CHAIN_ID));
|
||||
|
||||
final List<Long> heights = enforcedHeights();
|
||||
final List<NodeConfiguration> configurations =
|
||||
availableConfigurations(schedule, before, after);
|
||||
|
||||
final List<String> report = new ArrayList<>();
|
||||
NodeConfiguration complete = null;
|
||||
for (final NodeConfiguration c : configurations) {
|
||||
final List<Long> refused = new ArrayList<>();
|
||||
for (final long h : heights) {
|
||||
if (!c.satisfiesAt(h)) {
|
||||
refused.add(h);
|
||||
}
|
||||
}
|
||||
report.add(c.describe() + " is refused at " + refused);
|
||||
if (refused.isEmpty()) {
|
||||
complete = c;
|
||||
}
|
||||
}
|
||||
|
||||
assertThat(complete)
|
||||
.withFailMessage(
|
||||
"ROTATION IS NOT USABLE: one scheduled rotation at height %d leaves NO node configuration "
|
||||
+ "that "
|
||||
+ "satisfies the registry binding at every enforced height. %s. A node that cannot "
|
||||
+ "satisfy the binding at a height cannot import a header at that height "
|
||||
+ "(PqRegistryBindingRule is DETACHED, so it runs on the header-download path), and "
|
||||
+ "PqAnchorSyncModeGuard forces FULL sync when the anchor is armed, so every node "
|
||||
+ "acquiring history must pass through the pre-rotation interval AND reach the head. "
|
||||
+ "Using the rotation mechanism once therefore makes the chain permanently "
|
||||
+ "unjoinable. A rotation scheme must keep the whole HEIGHT-TO-KEY-SET mapping "
|
||||
+ "loadable, not only the current entry.",
|
||||
H2,
|
||||
String.join("; ", report))
|
||||
.isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void revocationDoesNotBrickHistory(@TempDir final Path dir) throws IOException {
|
||||
// Revocation is the same wire shape as rotation and is measured separately because it is the
|
||||
// case with a deadline: a compromised Falcon key has to stop counting, and the operator has no
|
||||
// reason to be able to re-sync afterwards only by luck.
|
||||
final PqRegistryHash.Registry withCompromised =
|
||||
PqRegistryHash.loadPropertiesRegistry(writeRegistry(dir, "compromised.properties", -1));
|
||||
final PqRegistryHash.Registry revoked =
|
||||
PqRegistryHash.loadPropertiesRegistry(writeRegistry(dir, "revoked.properties", 5));
|
||||
final PqRegistryHash.Schedule schedule =
|
||||
scheduleOf(
|
||||
PqRegistryHash.hashV1(withCompromised, CHAIN_ID),
|
||||
PqRegistryHash.hashV1(revoked, CHAIN_ID));
|
||||
|
||||
NodeConfiguration complete = null;
|
||||
final List<String> report = new ArrayList<>();
|
||||
for (final NodeConfiguration c : availableConfigurations(schedule, withCompromised, revoked)) {
|
||||
boolean all = true;
|
||||
final List<Long> refused = new ArrayList<>();
|
||||
for (final long h : enforcedHeights()) {
|
||||
if (!c.satisfiesAt(h)) {
|
||||
all = false;
|
||||
refused.add(h);
|
||||
}
|
||||
}
|
||||
report.add(c.describe() + " is refused at " + refused);
|
||||
if (all) {
|
||||
complete = c;
|
||||
}
|
||||
}
|
||||
|
||||
assertThat(complete)
|
||||
.withFailMessage(
|
||||
"REVOCATION IS NOT USABLE: revoking one signer at height %d leaves NO node "
|
||||
+ "configuration that satisfies the binding at every enforced height. %s. The "
|
||||
+ "revocation is expressible and is not usable: performing it costs the ability to "
|
||||
+ "acquire the chain.",
|
||||
H2,
|
||||
String.join("; ", report))
|
||||
.isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void theKeySetInForceBelowTheRotationIsTheOldOne(@TempDir final Path dir)
|
||||
throws IOException {
|
||||
// Coverage alone would be satisfied by a set that answered every height with the same registry.
|
||||
// This is the positive proof that the height actually selects: an old block resolves to the OLD
|
||||
// key set, which is the whole reason the history is kept.
|
||||
final PqRegistryHash.Registry before =
|
||||
PqRegistryHash.loadPropertiesRegistry(writeRegistry(dir, "before.properties", -1));
|
||||
final PqRegistryHash.Registry after =
|
||||
PqRegistryHash.loadPropertiesRegistry(writeRegistry(dir, "after.properties", 3));
|
||||
final PqRegistryHash.Schedule schedule =
|
||||
scheduleOf(PqRegistryHash.hashV1(before, CHAIN_ID), PqRegistryHash.hashV1(after, CHAIN_ID));
|
||||
final List<PqRegistryHash.Registry> held = new ArrayList<>();
|
||||
held.add(before);
|
||||
held.add(after);
|
||||
final PqRegistryHash.RegistrySet set = PqRegistryHash.buildSet(schedule, held, CHAIN_ID);
|
||||
|
||||
assertThat(set.coversWholeSchedule()).isTrue();
|
||||
assertThat(PqRegistryHash.registryAt(schedule, set, H1 - 1)).isEmpty();
|
||||
assertThat(PqRegistryHash.registryAt(schedule, set, H2 - 1).orElseThrow()).isSameAs(before);
|
||||
assertThat(PqRegistryHash.registryAt(schedule, set, H2).orElseThrow()).isSameAs(after);
|
||||
|
||||
// And the two really do differ at the rotated index, so "same registry everywhere" could not
|
||||
// have produced the answers above.
|
||||
assertThat(PqRegistryHash.fingerprint(before, 3))
|
||||
.isNotEqualTo(PqRegistryHash.fingerprint(after, 3));
|
||||
assertThat(PqRegistryHash.fingerprint(before, 0)).isEqualTo(PqRegistryHash.fingerprint(after, 0));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aMissingHistoricalRegistryIsNamedAndFailsClosed(@TempDir final Path dir)
|
||||
throws IOException {
|
||||
// The repair must not turn "I do not hold that registry" into "fine". An uncovered entry is
|
||||
// named by height and refuses at exactly the heights it governs, and nowhere else.
|
||||
final PqRegistryHash.Registry before =
|
||||
PqRegistryHash.loadPropertiesRegistry(writeRegistry(dir, "before.properties", -1));
|
||||
final PqRegistryHash.Registry after =
|
||||
PqRegistryHash.loadPropertiesRegistry(writeRegistry(dir, "after.properties", 3));
|
||||
final PqRegistryHash.Schedule schedule =
|
||||
scheduleOf(PqRegistryHash.hashV1(before, CHAIN_ID), PqRegistryHash.hashV1(after, CHAIN_ID));
|
||||
|
||||
final List<PqRegistryHash.Registry> onlyAfter = new ArrayList<>();
|
||||
onlyAfter.add(after);
|
||||
final PqRegistryHash.RegistrySet partial =
|
||||
PqRegistryHash.buildSet(schedule, onlyAfter, CHAIN_ID);
|
||||
|
||||
assertThat(partial.coversWholeSchedule()).isFalse();
|
||||
assertThat(partial.uncoveredEntryBlocks()).containsExactly(H1);
|
||||
assertThat(PqRegistryHash.matchesAt(schedule, partial, H1, CHAIN_ID)).isFalse();
|
||||
assertThat(PqRegistryHash.matchesAt(schedule, partial, H2 - 1, CHAIN_ID)).isFalse();
|
||||
assertThat(PqRegistryHash.matchesAt(schedule, partial, H2, CHAIN_ID)).isTrue();
|
||||
// Below the schedule nothing is enforced, so an incomplete set still leaves history alone.
|
||||
assertThat(PqRegistryHash.matchesAt(schedule, partial, H1 - 1, CHAIN_ID)).isTrue();
|
||||
// And a null set is refused wherever a binding is active, never passed over.
|
||||
assertThat(PqRegistryHash.matchesAt(schedule, (PqRegistryHash.RegistrySet) null, H1, CHAIN_ID))
|
||||
.isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void theOperatorConfigurationStringProducesACoveringSet(@TempDir final Path dir)
|
||||
throws IOException {
|
||||
// WHY THIS EXISTS, and it is a gap the other six leave open on purpose-by-omission. Every one of
|
||||
// them reaches the covering configuration by calling PqRegistryHash.buildSet with a list of
|
||||
// Registry objects the test built itself. No operator can do that. What an operator can do is
|
||||
// write a comma-separated list of FILE PATHS into aere.falcon.registry.history, and the node
|
||||
// turns that string into the same set through parseRegistryPaths + loadAuto
|
||||
// (FalconSealSupport.verifyRegistryBindingOrAbort, the D-081 block). If that route were broken
|
||||
// the other six would still be green and the capability would still not be usable, which is the
|
||||
// exact shape of "a green result in a reduced environment is true and worthless".
|
||||
//
|
||||
// So this measurement starts from the STRING and ends at the same property the measurement
|
||||
// tests assert: satisfied at every enforced height.
|
||||
final Path beforePath = writeRegistry(dir, "before.properties", -1);
|
||||
final Path afterPath = writeRegistry(dir, "after.properties", 3);
|
||||
final PqRegistryHash.Registry before = PqRegistryHash.loadPropertiesRegistry(beforePath);
|
||||
final PqRegistryHash.Registry after = PqRegistryHash.loadPropertiesRegistry(afterPath);
|
||||
final PqRegistryHash.Schedule schedule =
|
||||
scheduleOf(PqRegistryHash.hashV1(before, CHAIN_ID), PqRegistryHash.hashV1(after, CHAIN_ID));
|
||||
|
||||
// Written the way an operator writes it: one string, comma separated, with the sloppy spacing
|
||||
// a unit file actually carries. The node's own file is the FIRST element of the held list, so
|
||||
// the string names the OTHER one; here both are named, which is also legal and must not
|
||||
// double-count.
|
||||
final String configured = " " + beforePath + " , " + afterPath + " ,";
|
||||
final List<Path> paths = PqRegistryHash.parseRegistryPaths(configured);
|
||||
assertThat(paths).hasSize(2);
|
||||
|
||||
final PqRegistryHash.RegistrySet set = PqRegistryHash.loadSet(schedule, paths, CHAIN_ID);
|
||||
assertThat(set.coversWholeSchedule()).isTrue();
|
||||
assertThat(set.uncoveredEntryBlocks()).isEmpty();
|
||||
|
||||
final List<Long> refused = new ArrayList<>();
|
||||
for (final long h : enforcedHeights()) {
|
||||
if (!PqRegistryHash.matchesAt(schedule, set, h, CHAIN_ID)) {
|
||||
refused.add(h);
|
||||
}
|
||||
}
|
||||
assertThat(refused)
|
||||
.withFailMessage(
|
||||
"ROTATION IS NOT USABLE on the route an operator can actually take: the history list %s "
|
||||
+ "parses "
|
||||
+ "and loads, and the resulting set is still refused at %s. The library can express "
|
||||
+ "the whole height-to-key-set mapping but the configuration string cannot reach "
|
||||
+ "it, so the rotation remains expressible and not usable.",
|
||||
configured, refused)
|
||||
.isEmpty();
|
||||
|
||||
// Positive proof that the string, not luck, did the selecting: below the rotation the OLD file
|
||||
// is in force, at and above it the NEW one.
|
||||
assertThat(PqRegistryHash.registryAt(schedule, set, H2 - 1).orElseThrow())
|
||||
.isNotSameAs(PqRegistryHash.registryAt(schedule, set, H2).orElseThrow());
|
||||
|
||||
// And the failure direction on the same route: a history string that names only one of the two
|
||||
// files leaves the other entry uncovered, named by height, and refusing exactly there.
|
||||
final PqRegistryHash.RegistrySet partial =
|
||||
PqRegistryHash.loadSet(
|
||||
schedule, PqRegistryHash.parseRegistryPaths(afterPath.toString()), CHAIN_ID);
|
||||
assertThat(partial.uncoveredEntryBlocks()).containsExactly(H1);
|
||||
assertThat(PqRegistryHash.matchesAt(schedule, partial, H1, CHAIN_ID)).isFalse();
|
||||
}
|
||||
}
|
||||
@ -1,394 +0,0 @@
|
||||
/*
|
||||
* Copyright contributors to Besu / AERE Network.
|
||||
*
|
||||
* 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 org.hyperledger.besu.crypto.SecureRandomProvider;
|
||||
import org.hyperledger.besu.datatypes.Address;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.SecureRandom;
|
||||
|
||||
import org.apache.tuweni.bytes.Bytes;
|
||||
import org.bouncycastle.crypto.AsymmetricCipherKeyPair;
|
||||
import org.bouncycastle.crypto.digests.KeccakDigest;
|
||||
import org.bouncycastle.pqc.crypto.falcon.FalconKeyGenerationParameters;
|
||||
import org.bouncycastle.pqc.crypto.falcon.FalconKeyPairGenerator;
|
||||
import org.bouncycastle.pqc.crypto.falcon.FalconParameters;
|
||||
import org.bouncycastle.pqc.crypto.falcon.FalconPrivateKeyParameters;
|
||||
import org.bouncycastle.pqc.crypto.falcon.FalconPublicKeyParameters;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
/**
|
||||
* D-140. THE FLEET-RESTART DEADLOCK, AND THE STATE MACHINE THE REPAIR MOVES.
|
||||
*
|
||||
* <p>MEASURED FIRST, ON A NETWORK, NOT ASSUMED. The full activation rehearsal on a seven-node test
|
||||
* network (repetitie-activare-2026-08-05) found that with the anchor armed at K>0 a SIMULTANEOUS
|
||||
* restart of every validator stops the chain for good. The node said it verbatim: "refusing to
|
||||
* propose ... holds 0 valid eligible Falcon seal(s) ... threshold is 3", over "Attachment stays OFF
|
||||
* (fail-safe)".
|
||||
*
|
||||
* <p>THE CIRCLE. {@code activateLateAnchor()} used to be reachable from exactly one place, {@code
|
||||
* FalconSealValidationRule.tryActivateLateAnchor}, which runs only while a block is being IMPORTED.
|
||||
* After a fleet restart no block is imported, because nobody proposes. So {@code lateActivated}
|
||||
* stays false, {@link FalconSealSupport#attachmentArmed(long)} answers false, no seal is attached,
|
||||
* no certificate reaches K, and nobody can propose. Seals come from Commits, Commits come from
|
||||
* proposals, proposals need seals. With K=0 the chain heals itself. With K>0 it never does.
|
||||
*
|
||||
* <p>WHAT THIS CLASS MEASURES, and it is the state machine the repair moves, not a paraphrase of
|
||||
* it. The repair (QbftBesuControllerBuilder, marker "AERE BLOCAJ-REPORNIRE") adds a SECOND caller of
|
||||
* the SAME method at startup, reading the SAME contract slot 0 out of the chain-head world state.
|
||||
* So the question that decides whether the repair can work is exactly: does calling {@code
|
||||
* activateLateAnchor} with the on-chain hash, with no block imported and no other stimulus, turn
|
||||
* {@code attachmentArmed()} from false to true. Below, it does.
|
||||
*
|
||||
* <ol>
|
||||
* <li>{@link #restartedFleetIsNotArmedAndDoesNotHealWithTime()} - the deadlock state itself. A
|
||||
* node whose late anchor is PENDING is past its attachment height and still refuses to
|
||||
* attach, at that height and at every height after it. Nothing in the process flips it.
|
||||
* <li>{@link #activatingFromTheChainHeadArmsAttachment()} - the repair's mechanism. One call with
|
||||
* the on-chain hash, and attachment is armed. This is the ONLY thing the startup code adds.
|
||||
* <li>{@link #aWrongOnChainHashLeavesAttachmentOffAndIsTerminal()} - THE NEGATIVE CONTROL. If the
|
||||
* hash does not match, activation must FAIL and attachment must stay OFF: the repair must not
|
||||
* have bought liveness by weakening the tamper check. It also stays terminally failed, so a
|
||||
* later correct hash does not resurrect it.
|
||||
* <li>{@link #activationIsIdempotentAcrossRepeatedStartupCalls()} - the scope control. The
|
||||
* startup call and the import-path call can both fire in one process; the second must be a
|
||||
* no-op rather than a second registry load.
|
||||
* <li>{@link #aGenesisAnchoredNodeIsArmedImmediatelyAfterRestart()} - the rehearsal's own
|
||||
* stimulus replayed against THIS tree, and it does not fail the way the network did. Read its
|
||||
* javadoc: the rehearsal binary predates D-078, and the line it logged came from a condition
|
||||
* this tree no longer contains.
|
||||
* </ol>
|
||||
*
|
||||
* <p>NOT MEASURED here, and named so it is not read as covered: that a real Besu process reads slot
|
||||
* 0 out of a real chain-head world state (that is world-state plumbing in the app module, and the
|
||||
* rehearsal network is the instrument for it), and that seven live nodes recover from a real
|
||||
* simultaneous restart with this binary. This class measures the decision the deadlock hinges on.
|
||||
*/
|
||||
public class D140FleetRestartArmingTest {
|
||||
|
||||
/**
|
||||
* Fleet size for THIS fixture. Not a statement about any live network: the 2026-08-05 decision
|
||||
* to stay at seven was reversed, and the set has been nine since 2026-08-12. Seven is kept here
|
||||
* because it is the size at which the margin arithmetic this class exercises is tightest.
|
||||
*/
|
||||
private static final int N = 7;
|
||||
|
||||
/** Height at which the anchor contract is expected to be observable. */
|
||||
private static final long OBSERVE = 1_000L;
|
||||
|
||||
/** Seal-attachment height, at or after OBSERVE. */
|
||||
private static final long ATTACH = 1_200L;
|
||||
|
||||
/** A chain head well past the attachment height: this is what a restart comes back to. */
|
||||
private static final long HEAD = 5_000L;
|
||||
|
||||
private static final String ANCHOR_ADDRESS = "0x0000000000000000000000000000000000000fa1";
|
||||
|
||||
@TempDir private Path tmp;
|
||||
|
||||
/** keccak256 over (addr20 || pk) for every index in order: what the anchor contract holds. */
|
||||
private String onChainHash;
|
||||
|
||||
/** The same registry, spelled as a GENESIS-anchored manifest (the rehearsal's own shape). */
|
||||
private Path genesisPath;
|
||||
|
||||
@BeforeEach
|
||||
public void setUp() throws Exception {
|
||||
resetFalconSingleton();
|
||||
|
||||
final SecureRandom rnd = SecureRandomProvider.createSecureRandom();
|
||||
final KeccakDigest kd = new KeccakDigest(256);
|
||||
final StringBuilder manifest = new StringBuilder("{\"count\":").append(N);
|
||||
final StringBuilder genesis =
|
||||
new StringBuilder("{\"config\":{\"aereFalconRegistry\":{\"count\":").append(N);
|
||||
for (int i = 0; i < N; i++) {
|
||||
final FalconKeyPairGenerator gen = new FalconKeyPairGenerator();
|
||||
gen.init(new FalconKeyGenerationParameters(rnd, FalconParameters.falcon_512));
|
||||
final AsymmetricCipherKeyPair kp = gen.generateKeyPair();
|
||||
final FalconPublicKeyParameters pub = (FalconPublicKeyParameters) kp.getPublic();
|
||||
final FalconPrivateKeyParameters priv = (FalconPrivateKeyParameters) kp.getPrivate();
|
||||
final Address addr = Address.fromHexString(String.format("0x%040x", 0xA00 + i));
|
||||
|
||||
// The pre-image is accumulated in lockstep with the manifest text, exactly the way a real
|
||||
// anchoring transaction is built, so the hash below is not copied out of the code under test.
|
||||
final byte[] addrBytes = addr.getBytes().toArray();
|
||||
kd.update(addrBytes, 0, addrBytes.length);
|
||||
kd.update(pub.getH(), 0, pub.getH().length);
|
||||
|
||||
final String entry =
|
||||
",\""
|
||||
+ i
|
||||
+ "\":{\"addr\":\""
|
||||
+ addr.toHexString()
|
||||
+ "\",\"pk\":\""
|
||||
+ Bytes.wrap(pub.getH()).toHexString()
|
||||
+ "\"}";
|
||||
manifest.append(entry);
|
||||
genesis.append(entry);
|
||||
|
||||
if (i == 0) {
|
||||
// This node is validator 0 and HOLDS a signing key, otherwise attachment is off for a
|
||||
// reason that has nothing to do with the deadlock and the measurement would be vacuous.
|
||||
final Path key0 = tmp.resolve("falcon-key-0.properties");
|
||||
Files.writeString(
|
||||
key0,
|
||||
"index=0\n"
|
||||
+ "f="
|
||||
+ Bytes.wrap(priv.getSpolyf()).toHexString()
|
||||
+ "\n"
|
||||
+ "g="
|
||||
+ Bytes.wrap(priv.getG()).toHexString()
|
||||
+ "\n"
|
||||
+ "F="
|
||||
+ Bytes.wrap(priv.getSpolyF()).toHexString()
|
||||
+ "\n"
|
||||
+ "pk="
|
||||
+ Bytes.wrap(pub.getH()).toHexString()
|
||||
+ "\n");
|
||||
System.setProperty("aere.falcon.key", key0.toAbsolutePath().toString());
|
||||
}
|
||||
}
|
||||
manifest.append("}");
|
||||
|
||||
final byte[] digest = new byte[32];
|
||||
kd.doFinal(digest, 0);
|
||||
onChainHash = Bytes.wrap(digest).toUnprefixedHexString();
|
||||
|
||||
final Path manifestPath = tmp.resolve("falcon-late-manifest.json");
|
||||
Files.writeString(manifestPath, manifest.toString());
|
||||
|
||||
// Same seven entries, anchored the way the rehearsal network anchored them: in genesis, with
|
||||
// the hash committed in the anchor contract's slot 0 through alloc storage.
|
||||
genesis
|
||||
.append("}},\"alloc\":{\"0000000000000000000000000000000000000fa1\":{\"storage\":{\"0x")
|
||||
.append("0".repeat(64))
|
||||
.append("\":\"0x")
|
||||
.append(onChainHash)
|
||||
.append("\"}}}}");
|
||||
genesisPath = tmp.resolve("genesis-registry.json");
|
||||
Files.writeString(genesisPath, genesis.toString());
|
||||
|
||||
System.setProperty("aere.falcon.manifest", manifestPath.toAbsolutePath().toString());
|
||||
System.setProperty("aere.falcon.anchor.address", ANCHOR_ADDRESS);
|
||||
System.setProperty("aere.falcon.anchor.block", Long.toString(OBSERVE));
|
||||
System.setProperty("aere.falcon.attachBlock", Long.toString(ATTACH));
|
||||
System.setProperty("aere.falcon.validatorCount", Integer.toString(N));
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
public void tearDown() throws Exception {
|
||||
for (final String p :
|
||||
new String[] {
|
||||
"aere.falcon.manifest",
|
||||
"aere.falcon.genesis",
|
||||
"aere.falcon.key",
|
||||
"aere.falcon.anchor.address",
|
||||
"aere.falcon.anchor.block",
|
||||
"aere.falcon.attachBlock",
|
||||
"aere.falcon.forkBlock",
|
||||
"aere.falcon.validatorCount"
|
||||
}) {
|
||||
System.clearProperty(p);
|
||||
}
|
||||
resetFalconSingleton();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// 1. The deadlock state, stated as a property.
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
public void restartedFleetIsNotArmedAndDoesNotHealWithTime() {
|
||||
final FalconSealSupport pqc = FalconSealSupport.instance();
|
||||
|
||||
assertThat(pqc.lateAnchorPending())
|
||||
.describedAs(
|
||||
"fixture control: the late-anchor manifest must LOAD and stay PENDING, or every "
|
||||
+ "assertion below is about a registry that was never configured")
|
||||
.isTrue();
|
||||
assertThat(pqc.lateAnchored()).isFalse();
|
||||
assertThat(pqc.lateAnchorFailed()).isFalse();
|
||||
assertThat(pqc.signingEnabled())
|
||||
.describedAs("fixture control: this node holds a Falcon key, so attachment is not off for "
|
||||
+ "the trivial reason")
|
||||
.isTrue();
|
||||
assertThat(pqc.attachBlock()).isEqualTo(ATTACH);
|
||||
|
||||
// This IS the post-restart state: the process has just started, the chain head is far past the
|
||||
// attachment height, and no block has been imported because nobody has proposed one.
|
||||
assertThat(pqc.attachmentArmed(HEAD))
|
||||
.describedAs(
|
||||
"the measured deadlock: attachment height long since passed, registry still pending, "
|
||||
+ "so no seal is attached and no certificate can ever reach K")
|
||||
.isFalse();
|
||||
|
||||
// And it does not heal. Time, and blocks that are never imported, change nothing.
|
||||
for (long n = HEAD; n <= HEAD + 10_000L; n += 1_000L) {
|
||||
assertThat(pqc.attachmentArmed(n))
|
||||
.describedAs("still not armed at height %s; nothing in the process flips it", n)
|
||||
.isFalse();
|
||||
}
|
||||
assertThat(pqc.registrySize())
|
||||
.describedAs("the registry is EMPTY while pending, which is why a seal cannot verify either")
|
||||
.isZero();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// 2. The repair's mechanism: the SECOND caller, the one startup adds.
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
public void activatingFromTheChainHeadArmsAttachment() {
|
||||
final FalconSealSupport pqc = FalconSealSupport.instance();
|
||||
assertThat(pqc.attachmentArmed(HEAD)).isFalse();
|
||||
|
||||
// Exactly what the startup repair does: hand over the 32-byte value read from the anchor
|
||||
// contract's slot 0 in the CHAIN-HEAD world state. No block is imported anywhere here.
|
||||
final boolean activated = pqc.activateLateAnchor(onChainHash);
|
||||
|
||||
assertThat(activated).isTrue();
|
||||
assertThat(pqc.lateAnchored()).isTrue();
|
||||
assertThat(pqc.lateAnchorPending()).isFalse();
|
||||
assertThat(pqc.registrySize()).isEqualTo(N);
|
||||
assertThat(pqc.addressBound())
|
||||
.describedAs("the activated registry must bind every index to an address, or a seal cannot "
|
||||
+ "be resolved to a signer")
|
||||
.isTrue();
|
||||
assertThat(pqc.attachmentArmed(HEAD))
|
||||
.describedAs(
|
||||
"THE REPAIR: one activation from chain-head state arms attachment, so a restarted "
|
||||
+ "validator emits Falcon-carrying Commits again, certificates reach K, and a "
|
||||
+ "proposer can propose. This is the edge the deadlock needed and did not have.")
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// 3. THE NEGATIVE CONTROL. The repair must not have bought liveness by weakening the check.
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
public void aWrongOnChainHashLeavesAttachmentOffAndIsTerminal() {
|
||||
final FalconSealSupport pqc = FalconSealSupport.instance();
|
||||
|
||||
// One flipped nibble: a tampered anchor, or a wrong manifest shipped to this node.
|
||||
final char first = onChainHash.charAt(0);
|
||||
final String wrong = (first == '0' ? '1' : '0') + onChainHash.substring(1);
|
||||
assertThat(wrong).isNotEqualTo(onChainHash).hasSize(64);
|
||||
|
||||
assertThat(pqc.activateLateAnchor(wrong))
|
||||
.describedAs("a mismatching anchor must NOT activate the registry")
|
||||
.isFalse();
|
||||
assertThat(pqc.lateAnchored()).isFalse();
|
||||
assertThat(pqc.lateAnchorFailed())
|
||||
.describedAs("and the mismatch must be TERMINAL, not merely 'not yet'")
|
||||
.isTrue();
|
||||
assertThat(pqc.registrySize())
|
||||
.describedAs("the registry stays EMPTY: fail-closed, not fail-open")
|
||||
.isZero();
|
||||
assertThat(pqc.attachmentArmed(HEAD))
|
||||
.describedAs(
|
||||
"attachment stays OFF after a failed activation. If this were true, the startup repair "
|
||||
+ "would have turned a tamper detection into an arming path.")
|
||||
.isFalse();
|
||||
|
||||
// And the correct hash afterwards does not resurrect it: a node that has seen a tampered anchor
|
||||
// stays refused, which is the same fail-closed rule the import path already had.
|
||||
assertThat(pqc.activateLateAnchor(onChainHash)).isFalse();
|
||||
assertThat(pqc.attachmentArmed(HEAD)).isFalse();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// 4. Scope control: two callers now exist in one process.
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
public void activationIsIdempotentAcrossRepeatedStartupCalls() {
|
||||
final FalconSealSupport pqc = FalconSealSupport.instance();
|
||||
|
||||
assertThat(pqc.activateLateAnchor(onChainHash)).isTrue();
|
||||
final int afterFirst = pqc.registrySize();
|
||||
|
||||
// The startup call has fired; the import path fires too, on the first block that arrives.
|
||||
assertThat(pqc.activateLateAnchor(onChainHash)).isTrue();
|
||||
assertThat(pqc.registrySize()).isEqualTo(afterFirst).isEqualTo(N);
|
||||
assertThat(pqc.attachmentArmed(HEAD)).isTrue();
|
||||
|
||||
// Even a garbage hash after activation cannot un-arm it: activation is a one-way latch, so a
|
||||
// second reader with a stale view cannot disarm a fleet that is already sealing.
|
||||
assertThat(pqc.activateLateAnchor("00".repeat(32))).isTrue();
|
||||
assertThat(pqc.lateAnchorFailed()).isFalse();
|
||||
assertThat(pqc.attachmentArmed(HEAD)).isTrue();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// 5. The rehearsal's OWN stimulus, replayed against THIS tree. Read the note before trusting it.
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The seven-node rehearsal ran a GENESIS-anchored registry, and the line it logged after the
|
||||
* simultaneous restart was the COVERAGE one: "no validator set has been observed yet, so registry
|
||||
* COVERAGE cannot be proven. Attachment stays OFF (fail-safe)". That condition does not exist in
|
||||
* this tree: {@code grep} for it returns nothing, because D-078 (2026-08-02) removed the fleet
|
||||
* question from the per-commit gate. The rehearsal binary was built from the 2026-08-01 tree,
|
||||
* which still had it.
|
||||
*
|
||||
* <p>So this test states what is true HERE: a genesis-anchored node, freshly constructed, with no
|
||||
* validator set observed and no block imported, IS armed. The rehearsal's measured deadlock is
|
||||
* closed for the genesis-anchored path by a repair that already landed - and NOT by the startup
|
||||
* repair this class is about.
|
||||
*
|
||||
* <p>Which is exactly why the startup repair is still needed: on the LATE-ANCHOR path, the one
|
||||
* the live chain must use because it cannot be re-genesised, {@code lateActivated} is still set
|
||||
* from one place only. Tests 1-3 measure that path.
|
||||
*
|
||||
* <p>NOT MEASURED: that seven live nodes on a genesis-anchored network recover from a
|
||||
* simultaneous restart with a binary built from this tree.
|
||||
*/
|
||||
@Test
|
||||
public void aGenesisAnchoredNodeIsArmedImmediatelyAfterRestart() throws Exception {
|
||||
System.clearProperty("aere.falcon.manifest");
|
||||
System.clearProperty("aere.falcon.anchor.address");
|
||||
System.clearProperty("aere.falcon.anchor.block");
|
||||
System.setProperty("aere.falcon.genesis", genesisPath.toAbsolutePath().toString());
|
||||
resetFalconSingleton();
|
||||
|
||||
final FalconSealSupport pqc = FalconSealSupport.instance();
|
||||
|
||||
assertThat(pqc.genesisAnchored())
|
||||
.describedAs("fixture control: the genesis manifest must verify against the anchored hash")
|
||||
.isTrue();
|
||||
assertThat(pqc.registrySize()).isEqualTo(N);
|
||||
assertThat(pqc.addressBound()).isTrue();
|
||||
assertThat(pqc.attachmentArmed(HEAD))
|
||||
.describedAs(
|
||||
"a genesis-anchored node arms with NO validator set observed and NO block imported. "
|
||||
+ "The rehearsal's coverage condition is gone from this tree.")
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
private static void resetFalconSingleton() throws Exception {
|
||||
final Field f = FalconSealSupport.class.getDeclaredField("instance");
|
||||
f.setAccessible(true);
|
||||
f.set(null, null);
|
||||
}
|
||||
}
|
||||
@ -1,621 +0,0 @@
|
||||
/*
|
||||
* Copyright contributors to Besu / AERE Network.
|
||||
*
|
||||
* 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.assertThatCode;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.mockito.Mockito.withSettings;
|
||||
|
||||
import org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer;
|
||||
import org.hyperledger.besu.consensus.common.validator.ValidatorProvider;
|
||||
import org.hyperledger.besu.crypto.SecureRandomProvider;
|
||||
import org.hyperledger.besu.datatypes.Address;
|
||||
import org.hyperledger.besu.ethereum.ProtocolContext;
|
||||
import org.hyperledger.besu.ethereum.core.BlockHeader;
|
||||
import org.hyperledger.besu.ethereum.core.BlockHeaderTestFixture;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.OptionalInt;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import org.apache.tuweni.bytes.Bytes;
|
||||
import org.apache.tuweni.bytes.Bytes32;
|
||||
import org.bouncycastle.crypto.digests.KeccakDigest;
|
||||
import org.bouncycastle.pqc.crypto.falcon.FalconPrivateKeyParameters;
|
||||
import org.bouncycastle.pqc.crypto.falcon.FalconSigner;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.quality.Strictness;
|
||||
|
||||
/**
|
||||
* D-141. THE SECOND HALF OF THE FLEET-RESTART CHAIN DEATH: the heard seals themselves.
|
||||
*
|
||||
* <p>MEASURED ON A NETWORK FIRST, NOT ASSUMED. With the anchor armed at K>0, a SIMULTANEOUS
|
||||
* restart of all seven validators kills the chain permanently (rehearsal
|
||||
* repetitie-activare-2026-08-05, isolated chain 330858). The FIRST half of that deadlock was the
|
||||
* registry, repaired the same day: it now activates at start-up from chain-head state, and all seven
|
||||
* nodes reported "activare a ancorei la PORNIRE din starea capului: REUSITA". The chain died anyway.
|
||||
* The refusal only changed shape, from "registry address-bound=false" to "registry
|
||||
* address-bound=TRUE ... Heard 0 seal(s)", frozen 150 s then 298 s.
|
||||
*
|
||||
* <p>THE SECOND CIRCLE. The Falcon seals over M(head) travel on nothing but the Commit messages of
|
||||
* the head block, and those are never replayed after a restart. They exist nowhere else: the head's
|
||||
* own header carries a certificate over its PARENT, not over itself. So every node came back holding
|
||||
* zero seals, none could reach K, none could propose, and therefore none ever sent another Commit
|
||||
* for another node to hear. Seals come from Commits, Commits come from proposals, proposals need
|
||||
* seals.
|
||||
*
|
||||
* <p>WHAT THIS CLASS MEASURES, one test per link, with the causal chain driven in BOTH directions so
|
||||
* that "refuses" is never satisfied by a producer that always refuses:
|
||||
*
|
||||
* <ol>
|
||||
* <li>{@link #restartWithNoFileIsTheMeasuredDeadlockAndTheFileIsTheWayOut()} - the whole thing
|
||||
* end to end: the same node, the same head, the same K. Without the file the producer throws;
|
||||
* with the file restored it produces a K-seal certificate whose digest matches. This is the
|
||||
* chain death and its exit, in one method.
|
||||
* <li>{@link #aForgedSealInTheFileIsRejectedAtReadAndNeverEntersTheCache()} - THE SECURITY
|
||||
* PROPERTY, and the test the build-time negative control turns RED. Three shapes of forgery in
|
||||
* one file: a signature over the wrong message, random bytes, and a genuine seal re-labelled
|
||||
* under someone else's index. None survives, and the genuine ones alongside them do.
|
||||
* <li>{@link #aCorruptFileDoesNotStopTheNode()} - truncated, random, empty, a directory where the
|
||||
* file should be. Every one of them yields an empty cache and no exception.
|
||||
* <li>{@link #aFileFromAnotherHeightOrAnotherChainIsIgnored()} - the binding checks, before a
|
||||
* single signature is verified.
|
||||
* <li>{@link #theWriteIsAtomicUnderAConcurrentReader()} - a reader hammering the file across 120
|
||||
* writes never observes a partial file.
|
||||
* <li>{@link #thePathComesFromTheDataDirectory()} - the path is derived, never configured.
|
||||
* <li>{@link #whatOneWriteCostsAgainstTheBlockInterval()} - the price of doing this on the
|
||||
* consensus thread, as a number rather than as a hope.
|
||||
* </ol>
|
||||
*
|
||||
* <p>NOT MEASURED here, and named so it is not read as covered: that seven live nodes recover from a
|
||||
* real simultaneous restart with a binary built from this tree. That needs the rehearsal network and
|
||||
* is separate evidence. This class measures every decision that recovery depends on.
|
||||
*/
|
||||
public class D141SealPersistenceTest {
|
||||
|
||||
/** Anchor activation height H. */
|
||||
private static final long H = 1_000L;
|
||||
|
||||
/** Seal-attachment height, comfortably below H. */
|
||||
private static final long ATTACH = 900L;
|
||||
|
||||
/** Height from which the staged threshold K is in force. */
|
||||
private static final long K_AT = H + 10L;
|
||||
|
||||
/** The founder's decision of 2026-08-05: N=7 stays, and K=3 is the value with full margin. */
|
||||
private static final int K = 3;
|
||||
|
||||
private static final int N = 7;
|
||||
|
||||
private static final long CHAIN_ID = 2_800L;
|
||||
|
||||
/** Measured block interval on the live chain, in milliseconds. */
|
||||
private static final long BLOCK_INTERVAL_MS = 523L;
|
||||
|
||||
@TempDir private Path tmp;
|
||||
|
||||
/** Stands in for the node's data directory, which is where the real path comes from. */
|
||||
private Path dataDirectory;
|
||||
|
||||
private final List<Address> validators = new ArrayList<>();
|
||||
private final List<FalconPrivateKeyParameters> privateKeys = new ArrayList<>();
|
||||
private BlockHeader head;
|
||||
private ProtocolContext context;
|
||||
private BftExtraData base;
|
||||
|
||||
@BeforeEach
|
||||
public void setUp() throws Exception {
|
||||
dataDirectory = Files.createDirectories(tmp.resolve("besu-data"));
|
||||
|
||||
// AERE D-146 (2026-08-06): v2, proof-bound, bound at H. See PqV2Fixture.
|
||||
final KeccakDigest kd = new KeccakDigest(256);
|
||||
final StringBuilder manifest =
|
||||
new StringBuilder("{\"config\":{\"aereFalconRegistry\":{")
|
||||
.append(PqV2Fixture.manifestHeader(N, CHAIN_ID, H));
|
||||
for (int i = 0; i < N; i++) {
|
||||
privateKeys.add(PqV2Fixture.privateKey(i));
|
||||
validators.add(PqV2Fixture.address(i));
|
||||
final byte[] anchoredRow = PqV2Fixture.anchorPreimageRow(i);
|
||||
kd.update(anchoredRow, 0, anchoredRow.length);
|
||||
manifest.append(',').append(PqV2Fixture.manifestEntry(i, N, CHAIN_ID, H));
|
||||
}
|
||||
final byte[] anchoredHash = new byte[32];
|
||||
kd.doFinal(anchoredHash, 0);
|
||||
manifest
|
||||
.append("}},\"alloc\":{\"0000000000000000000000000000000000000fa1\":{\"storage\":{\"0x")
|
||||
.append("0".repeat(64))
|
||||
.append("\":\"0x")
|
||||
.append(Bytes.wrap(anchoredHash).toUnprefixedHexString())
|
||||
.append("\"}}}}");
|
||||
final Path genesisPath = tmp.resolve("genesis-registry.json");
|
||||
Files.writeString(genesisPath, manifest.toString());
|
||||
System.setProperty("aere.falcon.genesis", genesisPath.toAbsolutePath().toString());
|
||||
System.setProperty("aere.falcon.attachBlock", Long.toString(ATTACH));
|
||||
|
||||
resetFalconSingleton();
|
||||
PqSealCache.instance().disablePersistence();
|
||||
PqSealCache.instance().clear();
|
||||
PqAnchorProducer.useConfigForTesting(
|
||||
new PqAnchorConfig(CHAIN_ID, H, Map.of(H, 0, K_AT, K), OptionalInt.empty(), false));
|
||||
|
||||
head = new BlockHeaderTestFixture().number(K_AT + 20L).buildHeader();
|
||||
context = contextWith(validators);
|
||||
base =
|
||||
new BftExtraData(
|
||||
Bytes32.ZERO,
|
||||
Collections.emptyList(),
|
||||
Optional.empty(),
|
||||
0,
|
||||
validators,
|
||||
Collections.emptyList());
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
public void tearDown() throws Exception {
|
||||
System.clearProperty("aere.falcon.genesis");
|
||||
System.clearProperty("aere.falcon.attachBlock");
|
||||
PqSealCache.instance().disablePersistence();
|
||||
PqSealCache.instance().clear();
|
||||
PqAnchorProducer.useConfigForTesting(null);
|
||||
resetFalconSingleton();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// 1. The chain death, and its exit, in one method.
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
public void restartWithNoFileIsTheMeasuredDeadlockAndTheFileIsTheWayOut() {
|
||||
// Fixture control: without a genuinely armed registry every assertion below would be about
|
||||
// nothing at all.
|
||||
assertThat(FalconSealSupport.instance().genesisAnchored()).isTrue();
|
||||
assertThat(FalconSealSupport.instance().addressBound()).isTrue();
|
||||
assertThat(FalconSealSupport.instance().registrySize()).isEqualTo(N);
|
||||
|
||||
// --- BEFORE THE RESTART. The node is running, persistence is on, and it hears K Commits for
|
||||
// its head exactly the way QbftRound.pqCacheHeardSeals feeds them in.
|
||||
PqSealCache.instance().enablePersistence(dataDirectory, CHAIN_ID);
|
||||
final Path file = PqSealStore.fileIn(dataDirectory);
|
||||
for (int i = 0; i < K; i++) {
|
||||
PqSealCache.instance().record(head.getNumber(), head.getHash(), List.of(genuineSeal(i)));
|
||||
}
|
||||
assertThat(file).exists();
|
||||
assertThat(PqSealCache.instance().sealCount(head.getHash())).isEqualTo(K);
|
||||
|
||||
// --- THE RESTART. A fresh process: the map is gone, the file is not. Nothing else changes.
|
||||
PqSealCache.instance().clear();
|
||||
assertThat(PqSealCache.instance().sealCount(head.getHash()))
|
||||
.describedAs("the in-memory map does not survive a restart, which is the whole problem")
|
||||
.isZero();
|
||||
|
||||
// --- THE DEADLOCK, as measured on the seven-node network. Without the file this is terminal:
|
||||
// no proposal means no Commit, and no Commit means no seal, for ever.
|
||||
assertThatThrownBy(() -> PqAnchorProducer.apply(base, head, context))
|
||||
.describedAs(
|
||||
"the measured chain death: a restarted node holds no seals over M(head), so it cannot "
|
||||
+ "assemble a certificate and cannot propose")
|
||||
.isInstanceOf(PqAnchorNotReadyException.class)
|
||||
.hasMessageContaining("Heard 0 seal(s)");
|
||||
|
||||
// --- THE REPAIR. One read, every seal re-verified, and the same producer on the same inputs
|
||||
// now produces a certificate. This is the ONLY thing the start-up code adds.
|
||||
final int restored =
|
||||
PqSealCache.instance()
|
||||
.restoreFromDisk(
|
||||
head.getNumber(), head.getHash(), PqSignerRegistry.falconSealSupport());
|
||||
assertThat(restored).isEqualTo(K);
|
||||
assertThat(PqSealCache.instance().sealCount(head.getHash())).isEqualTo(K);
|
||||
|
||||
final BftExtraData produced = PqAnchorProducer.apply(base, head, context);
|
||||
assertThat(produced.getFalconSeals())
|
||||
.describedAs("the restarted node can propose again, carrying a K=%d certificate", K)
|
||||
.hasSize(K);
|
||||
assertThat(PqAnchor.hasStrictlyIncreasingIndices(produced.getFalconSeals())).isTrue();
|
||||
assertThat(produced.getVanityData())
|
||||
.describedAs("and the anchor digest is the one the validator side will recompute")
|
||||
.isEqualTo(
|
||||
PqAnchor.anchorDigest(
|
||||
CHAIN_ID,
|
||||
head.getNumber(),
|
||||
head.getHash().getBytes(),
|
||||
PqAnchor.sortedByIndex(produced.getFalconSeals())));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// 2. THE SECURITY PROPERTY. This is the test the build-time negative control turns RED.
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Persisting seals is only defensible because a seal is SELF-AUTHENTICATING: it is re-verified at
|
||||
* read, against the anchored registry, over M rebuilt from the head this process just loaded. If
|
||||
* that were not so, the file would be exactly defect A8 in another coat - state believed because
|
||||
* it sits in a file a node can be pointed at.
|
||||
*
|
||||
* <p>Three shapes of forgery are in the one file, because "a forged seal" is not one thing:
|
||||
*
|
||||
* <ol>
|
||||
* <li>index 3, a REAL Falcon signature by validator 3, but over another block's M. This is the
|
||||
* replay an attacker with access to any past Commit traffic actually has.
|
||||
* <li>index 4, random bytes of exactly the right length. The cheapest forgery there is.
|
||||
* <li>index 5, validator 0's GENUINE signature over the right M, re-labelled as index 5. This
|
||||
* one is the reason index alone can never be the check: the bytes are valid, the claim is
|
||||
* not.
|
||||
* </ol>
|
||||
*/
|
||||
@Test
|
||||
public void aForgedSealInTheFileIsRejectedAtReadAndNeverEntersTheCache() throws Exception {
|
||||
final List<FalconSeal> genuine = List.of(genuineSeal(0), genuineSeal(1), genuineSeal(2));
|
||||
|
||||
final Bytes32 anotherBlocksMessage =
|
||||
PqAnchor.commitMessage(CHAIN_ID, head.getNumber() - 1L, Bytes32.leftPad(Bytes.of(9)));
|
||||
final byte[] randomBytes = new byte[genuine.get(0).getSignature().size()];
|
||||
SecureRandomProvider.createSecureRandom().nextBytes(randomBytes);
|
||||
|
||||
final List<FalconSeal> forged =
|
||||
List.of(
|
||||
new FalconSeal(3, Bytes.wrap(falconSign(privateKeys.get(3), anotherBlocksMessage))),
|
||||
new FalconSeal(4, Bytes.wrap(randomBytes)),
|
||||
new FalconSeal(5, genuine.get(0).getSignature()));
|
||||
|
||||
final List<FalconSeal> all = new ArrayList<>(genuine);
|
||||
all.addAll(forged);
|
||||
final Path file = PqSealStore.fileIn(dataDirectory);
|
||||
PqSealStore.writeAtomically(
|
||||
file,
|
||||
PqSealStore.encode(CHAIN_ID, head.getNumber(), head.getHash().getBytes(), all));
|
||||
assertThat(file).exists();
|
||||
|
||||
PqSealCache.instance().enablePersistence(dataDirectory, CHAIN_ID);
|
||||
final int restored =
|
||||
PqSealCache.instance()
|
||||
.restoreFromDisk(
|
||||
head.getNumber(), head.getHash(), PqSignerRegistry.falconSealSupport());
|
||||
|
||||
assertThat(restored)
|
||||
.describedAs(
|
||||
"THE LOAD-BEARING ASSERTION. Six seals were in the file and only the three genuine ones "
|
||||
+ "may come out. Delete the verify() call in PqSealStore and this line goes red, "
|
||||
+ "which is exactly what the build-time negative control proves.")
|
||||
.isEqualTo(3);
|
||||
|
||||
final List<FalconSeal> inCache =
|
||||
PqSealCache.instance().sealsFor(head.getNumber(), head.getHash());
|
||||
assertThat(inCache).hasSize(3);
|
||||
assertThat(inCache.stream().map(FalconSeal::getValidatorIndex))
|
||||
.describedAs("no forged index may reach the cache at all")
|
||||
.containsExactly(0, 1, 2);
|
||||
assertThat(inCache).containsExactlyInAnyOrderElementsOf(genuine);
|
||||
|
||||
// And the genuine ones are not merely present, they are usable: the producer, which verifies
|
||||
// again at selection, accepts exactly these three. Without this half the test would be
|
||||
// satisfied by a reader that rejected everything.
|
||||
final BftExtraData produced = PqAnchorProducer.apply(base, head, context);
|
||||
assertThat(produced.getFalconSeals()).hasSize(K).containsExactlyElementsOf(genuine);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// 3. A corrupt file must never be able to stop a node.
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
public void aCorruptFileDoesNotStopTheNode() throws Exception {
|
||||
final Path file = PqSealStore.fileIn(dataDirectory);
|
||||
final byte[] good =
|
||||
PqSealStore.encode(
|
||||
CHAIN_ID,
|
||||
head.getNumber(),
|
||||
head.getHash().getBytes(),
|
||||
List.of(genuineSeal(0), genuineSeal(1), genuineSeal(2)));
|
||||
PqSealCache.instance().enablePersistence(dataDirectory, CHAIN_ID);
|
||||
|
||||
// (a) no file at all: the ordinary first start.
|
||||
Files.deleteIfExists(file);
|
||||
assertRestoresNothingWithoutThrowing("no file at all");
|
||||
|
||||
// (b) truncated halfway: a write interrupted by a machine that lost power. The atomic rename
|
||||
// is what stops this from happening, and this is what would happen if it did anyway.
|
||||
Files.write(file, Arrays.copyOf(good, good.length / 2));
|
||||
assertRestoresNothingWithoutThrowing("truncated file");
|
||||
|
||||
// (c) random bytes: a wrong file copied over it, or a corrupt sector.
|
||||
final byte[] noise = new byte[good.length];
|
||||
SecureRandomProvider.createSecureRandom().nextBytes(noise);
|
||||
Files.write(file, noise);
|
||||
assertRestoresNothingWithoutThrowing("random bytes");
|
||||
|
||||
// (d) empty file.
|
||||
Files.write(file, new byte[0]);
|
||||
assertRestoresNothingWithoutThrowing("empty file");
|
||||
|
||||
// (e) valid RLP, wrong domain: a file written for something else entirely.
|
||||
Files.write(file, Bytes.fromHexString("0xc50102030405").toArrayUnsafe());
|
||||
assertRestoresNothingWithoutThrowing("valid RLP, wrong shape");
|
||||
|
||||
// (f) a DIRECTORY where the file should be. Not exotic: a mount gone wrong does this.
|
||||
Files.deleteIfExists(file);
|
||||
Files.createDirectory(file);
|
||||
assertRestoresNothingWithoutThrowing("a directory in place of the file");
|
||||
Files.delete(file);
|
||||
|
||||
// And after all of that the node is still a working node: a good file still restores.
|
||||
Files.write(file, good);
|
||||
assertThat(
|
||||
PqSealCache.instance()
|
||||
.restoreFromDisk(
|
||||
head.getNumber(), head.getHash(), PqSignerRegistry.falconSealSupport()))
|
||||
.describedAs(
|
||||
"positive control: without this line every assertion above would be satisfied by a "
|
||||
+ "reader that can never read anything")
|
||||
.isEqualTo(3);
|
||||
}
|
||||
|
||||
private void assertRestoresNothingWithoutThrowing(final String what) {
|
||||
PqSealCache.instance().clear();
|
||||
assertThatCode(
|
||||
() ->
|
||||
assertThat(
|
||||
PqSealCache.instance()
|
||||
.restoreFromDisk(
|
||||
head.getNumber(),
|
||||
head.getHash(),
|
||||
PqSignerRegistry.falconSealSupport()))
|
||||
.describedAs("%s must restore nothing", what)
|
||||
.isZero())
|
||||
.describedAs("%s must not throw: a node that cannot read the file is a node with none", what)
|
||||
.doesNotThrowAnyException();
|
||||
assertThat(PqSealCache.instance().sealCount(head.getHash())).isZero();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// 4. The binding checks, made before any signature is verified.
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
public void aFileFromAnotherHeightOrAnotherChainIsIgnored() throws Exception {
|
||||
final Path file = PqSealStore.fileIn(dataDirectory);
|
||||
final List<FalconSeal> seals = List.of(genuineSeal(0), genuineSeal(1), genuineSeal(2));
|
||||
PqSealCache.instance().enablePersistence(dataDirectory, CHAIN_ID);
|
||||
|
||||
// Same seals, but the file claims another height. They cannot help the proposer of head+1.
|
||||
PqSealStore.writeAtomically(
|
||||
file,
|
||||
PqSealStore.encode(CHAIN_ID, head.getNumber() - 1L, head.getHash().getBytes(), seals));
|
||||
assertRestoresNothingWithoutThrowing("a file from another height");
|
||||
|
||||
// Same seals, another block hash at the right height: a fork of the same number.
|
||||
PqSealStore.writeAtomically(
|
||||
file,
|
||||
PqSealStore.encode(
|
||||
CHAIN_ID, head.getNumber(), Bytes32.leftPad(Bytes.of(7)), seals));
|
||||
assertRestoresNothingWithoutThrowing("a file for another block at the same height");
|
||||
|
||||
// Another chain running the same binaries and possibly the same Falcon keys.
|
||||
PqSealStore.writeAtomically(
|
||||
file,
|
||||
PqSealStore.encode(442_807L, head.getNumber(), head.getHash().getBytes(), seals));
|
||||
assertRestoresNothingWithoutThrowing("a file from another chain");
|
||||
|
||||
// Positive control for this method: the same three seals, correctly bound, do restore.
|
||||
PqSealStore.writeAtomically(
|
||||
file, PqSealStore.encode(CHAIN_ID, head.getNumber(), head.getHash().getBytes(), seals));
|
||||
PqSealCache.instance().clear();
|
||||
assertThat(
|
||||
PqSealCache.instance()
|
||||
.restoreFromDisk(
|
||||
head.getNumber(), head.getHash(), PqSignerRegistry.falconSealSupport()))
|
||||
.isEqualTo(3);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// 5. Atomicity, measured against a reader rather than asserted from the API docs.
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
public void theWriteIsAtomicUnderAConcurrentReader() throws Exception {
|
||||
final Path file = PqSealStore.fileIn(dataDirectory);
|
||||
final List<FalconSeal> seals = new ArrayList<>();
|
||||
for (int i = 0; i < N; i++) {
|
||||
seals.add(genuineSeal(i));
|
||||
}
|
||||
PqSealStore.writeAtomically(
|
||||
file, PqSealStore.encode(CHAIN_ID, head.getNumber(), head.getHash().getBytes(), seals));
|
||||
|
||||
final AtomicBoolean stop = new AtomicBoolean(false);
|
||||
final AtomicInteger reads = new AtomicInteger();
|
||||
final AtomicInteger partialReads = new AtomicInteger();
|
||||
final Thread reader =
|
||||
new Thread(
|
||||
() -> {
|
||||
while (!stop.get()) {
|
||||
final List<FalconSeal> got =
|
||||
PqSealStore.readVerified(
|
||||
file,
|
||||
CHAIN_ID,
|
||||
head.getNumber(),
|
||||
head.getHash(),
|
||||
PqSignerRegistry.falconSealSupport());
|
||||
reads.incrementAndGet();
|
||||
if (got.isEmpty()) {
|
||||
partialReads.incrementAndGet();
|
||||
}
|
||||
}
|
||||
});
|
||||
reader.setDaemon(true);
|
||||
reader.start();
|
||||
|
||||
for (int round = 0; round < 120; round++) {
|
||||
final List<FalconSeal> subset = seals.subList(0, 1 + (round % N));
|
||||
PqSealStore.writeAtomically(
|
||||
file,
|
||||
PqSealStore.encode(CHAIN_ID, head.getNumber(), head.getHash().getBytes(), subset));
|
||||
}
|
||||
stop.set(true);
|
||||
reader.join(30_000L);
|
||||
|
||||
assertThat(reads)
|
||||
.describedAs("fixture control: the reader must actually have run")
|
||||
.hasValueGreaterThan(0);
|
||||
assertThat(partialReads)
|
||||
.describedAs(
|
||||
"%s reads across 120 writes and not one saw a half-written file. Temp plus rename is "
|
||||
+ "the reason; writing in place would have produced partial reads here.",
|
||||
reads.get())
|
||||
.hasValue(0);
|
||||
assertThat(dataDirectory.resolve(PqSealStore.TEMP_FILE_NAME))
|
||||
.describedAs("the temporary file must not be left behind")
|
||||
.doesNotExist();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// 6. The path is DERIVED from the data directory, never separately configured.
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
public void thePathComesFromTheDataDirectory() {
|
||||
assertThat(PqSealStore.fileIn(dataDirectory))
|
||||
.isEqualTo(dataDirectory.resolve(PqSealStore.FILE_NAME));
|
||||
|
||||
PqSealCache.instance().enablePersistence(dataDirectory, CHAIN_ID);
|
||||
assertThat(PqSealCache.instance().persistenceFile())
|
||||
.isEqualTo(dataDirectory.resolve(PqSealStore.FILE_NAME));
|
||||
|
||||
final Path other = tmp.resolve("another-node");
|
||||
PqSealCache.instance().enablePersistence(other, CHAIN_ID);
|
||||
assertThat(PqSealCache.instance().persistenceFile())
|
||||
.describedAs("two nodes on one machine never share the file")
|
||||
.isEqualTo(other.resolve(PqSealStore.FILE_NAME))
|
||||
.isNotEqualTo(dataDirectory.resolve(PqSealStore.FILE_NAME));
|
||||
|
||||
// A null data directory leaves persistence off rather than inventing a path.
|
||||
PqSealCache.instance().disablePersistence();
|
||||
PqSealCache.instance().enablePersistence(null, CHAIN_ID);
|
||||
assertThat(PqSealCache.instance().persistenceFile()).isNull();
|
||||
PqSealCache.instance().record(head.getNumber(), head.getHash(), List.of(genuineSeal(0)));
|
||||
assertThat(PqSealCache.instance().sealCount(head.getHash()))
|
||||
.describedAs("with persistence off the cache still works exactly as before")
|
||||
.isEqualTo(1);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// 7. The price, as a number.
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The write happens on the consensus thread, once per Commit heard, so its cost is a real
|
||||
* property of this change and not a footnote. Seven seals is the whole fleet.
|
||||
*/
|
||||
@Test
|
||||
public void whatOneWriteCostsAgainstTheBlockInterval() throws Exception {
|
||||
final Path file = PqSealStore.fileIn(dataDirectory);
|
||||
final List<FalconSeal> seals = new ArrayList<>();
|
||||
for (int i = 0; i < N; i++) {
|
||||
seals.add(genuineSeal(i));
|
||||
}
|
||||
final byte[] payload =
|
||||
PqSealStore.encode(CHAIN_ID, head.getNumber(), head.getHash().getBytes(), seals);
|
||||
|
||||
final int rounds = 100;
|
||||
final long[] micros = new long[rounds];
|
||||
for (int i = 0; i < rounds; i++) {
|
||||
final long t0 = System.nanoTime();
|
||||
PqSealStore.writeAtomically(file, payload);
|
||||
micros[i] = (System.nanoTime() - t0) / 1_000L;
|
||||
}
|
||||
Arrays.sort(micros);
|
||||
final long median = micros[rounds / 2];
|
||||
final long p95 = micros[(int) (rounds * 0.95)];
|
||||
final long worst = micros[rounds - 1];
|
||||
|
||||
// Printed so the number lands in the test XML and can be quoted as a measurement rather than
|
||||
// remembered as an impression.
|
||||
System.out.println(
|
||||
"AERE PERSISTENTA-SIGILII MEASURED: payload="
|
||||
+ payload.length
|
||||
+ " bytes for "
|
||||
+ N
|
||||
+ " seals; write median="
|
||||
+ median
|
||||
+ " us, p95="
|
||||
+ p95
|
||||
+ " us, worst="
|
||||
+ worst
|
||||
+ " us over "
|
||||
+ rounds
|
||||
+ " writes; fsync="
|
||||
+ !"false".equalsIgnoreCase(System.getProperty(PqSealStore.PROPERTY_FSYNC))
|
||||
+ "; block interval="
|
||||
+ BLOCK_INTERVAL_MS
|
||||
+ " ms.");
|
||||
|
||||
assertThat(payload.length)
|
||||
.describedAs("seven Falcon-512 seals plus the binding fields")
|
||||
.isLessThan(16 * 1024);
|
||||
assertThat(median)
|
||||
.describedAs(
|
||||
"one write must cost far less than one block interval, or persisting on the consensus "
|
||||
+ "thread would be trading a restart deadlock for a liveness cost")
|
||||
.isLessThan(BLOCK_INTERVAL_MS * 1_000L / 10L);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// Helpers.
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
private FalconSeal genuineSeal(final int index) {
|
||||
final Bytes32 m = PqAnchor.commitMessage(CHAIN_ID, head.getNumber(), head.getHash().getBytes());
|
||||
return new FalconSeal(index, Bytes.wrap(falconSign(privateKeys.get(index), m)));
|
||||
}
|
||||
|
||||
private static byte[] falconSign(final FalconPrivateKeyParameters key, final Bytes32 m) {
|
||||
final FalconSigner signer = new FalconSigner();
|
||||
signer.init(true, key);
|
||||
return signer.generateSignature(m.toArray());
|
||||
}
|
||||
|
||||
private static ProtocolContext contextWith(final Collection<Address> validatorSet) {
|
||||
final ValidatorProvider validatorProvider =
|
||||
mock(ValidatorProvider.class, withSettings().strictness(Strictness.LENIENT));
|
||||
when(validatorProvider.getValidatorsForBlock(any())).thenReturn(validatorSet);
|
||||
when(validatorProvider.getValidatorsAfterBlock(any())).thenReturn(validatorSet);
|
||||
final BftContext bftContext =
|
||||
mock(BftContext.class, withSettings().strictness(Strictness.LENIENT));
|
||||
when(bftContext.getValidatorProvider()).thenReturn(validatorProvider);
|
||||
when(bftContext.as(any())).thenReturn(bftContext);
|
||||
return new ProtocolContext.Builder().withConsensusContext(bftContext).build();
|
||||
}
|
||||
|
||||
private static void resetFalconSingleton() throws Exception {
|
||||
final Field f = FalconSealSupport.class.getDeclaredField("instance");
|
||||
f.setAccessible(true);
|
||||
f.set(null, null);
|
||||
}
|
||||
}
|
||||
@ -1,387 +0,0 @@
|
||||
/*
|
||||
* Copyright contributors to Besu / AERE Network.
|
||||
*
|
||||
* 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.assertThatCode;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Properties;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.apache.tuweni.bytes.Bytes;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
/**
|
||||
* D-146, THE LINE THAT WAS MISSING. {@code PqRegistryHash.requireBindingsOrThrow} was delivered on
|
||||
* 2026-08-06 with its own tests, and nothing called it. Its own javadoc said so: "NOT WIRED YET ...
|
||||
* the call belongs beside AERE-PQC-REG-ARM-01 in FalconSealSupport, which is being edited by another
|
||||
* stream". This class measures the wire.
|
||||
*
|
||||
* <p>WHAT THE WIRE BUYS, stated as the thing that is actually true. Before it, an ARMED node loaded
|
||||
* a v1 registry without a word, and the registry decides who a Falcon seal is credited to. Measured
|
||||
* on the real verification path on the same day: two rows with their public keys swapped - four
|
||||
* distinct keys, four distinct addresses, so no uniqueness check would see anything - produced an
|
||||
* ACCEPTED header; and the same key filed at two indices satisfied a threshold of two with one
|
||||
* private key, which makes the threshold itself fiction.
|
||||
*
|
||||
* <p>WHY THE POSITIVE CONTROLS ARE THE EXPENSIVE HALF. A gate that refuses everything is not a gate,
|
||||
* it is an outage wearing a security message. The tests that cost the most to get right here are the
|
||||
* ones where the node STARTS: over a correct v2 registry, and over the very same v1 file when
|
||||
* nothing is armed.
|
||||
*
|
||||
* <p>WHY THE ANCHOR CASE IS TESTED SEPARATELY FROM THE FORK-BLOCK CASE. They are different triggers
|
||||
* and only one of them was previously guarded at all. {@code armingReadinessDiagnostic()} returns
|
||||
* immediately when {@code aere.falcon.forkBlock} is unset, so AERE-PQC-REG-ARM-01 has never fired on
|
||||
* a node armed through the certificate anchor. This guard fires on both, and {@link
|
||||
* #armedThroughTheANCHORAloneTheNodeAlsoREFUSES} is the half that has no predecessor.
|
||||
*
|
||||
* <p>WHAT IS NOT MEASURED HERE, written rather than implied: nothing is deployed, no node is
|
||||
* started, the fleet of seven is not touched, and every Falcon and ECDSA key below is a PROBE key
|
||||
* generated in this JVM. Whether the refusal behaves the same on the seven real boxes at a
|
||||
* coordinated restart is NOT MEASURED.
|
||||
*/
|
||||
public class D146ArmingGateTest {
|
||||
|
||||
/** The height at which this fixture arms Falcon blocking. */
|
||||
private static final long FORK = 7_000L;
|
||||
|
||||
/** Attachment must lead the fork block; the same shape D079ForkArmingTest uses. */
|
||||
private static final long ATTACH = 6_000L;
|
||||
|
||||
/** The chain id the registry is bound to. Not 2800: nothing here may look like the live fleet. */
|
||||
private static final long CHAIN_ID = 220_878L;
|
||||
|
||||
/** The height the binding proofs are signed for. */
|
||||
private static final long BIND_HEIGHT = FORK;
|
||||
|
||||
private static final int N = 4;
|
||||
|
||||
/** Every property this class is allowed to touch. Cleared before AND after every test. */
|
||||
private static final List<String> OWNED_PROPERTIES =
|
||||
List.of(
|
||||
"aere.falcon.registry",
|
||||
"aere.falcon.forkBlock",
|
||||
"aere.falcon.attachBlock",
|
||||
"aere.falcon.validatorCount",
|
||||
"aere.falcon.testnetAllowSmallFleet",
|
||||
PqAnchorConfig.PROPERTY_ANCHOR_BLOCK,
|
||||
PqAnchorConfig.PROPERTY_CHAIN_ID,
|
||||
PqAnchorConfig.PROPERTY_MIN_SEALS);
|
||||
|
||||
@TempDir private Path tmp;
|
||||
|
||||
@BeforeEach
|
||||
public void setUp() throws Exception {
|
||||
clearOwnedProperties();
|
||||
forgetAnchorConfig();
|
||||
resetFalconSingleton();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
public void tearDown() throws Exception {
|
||||
clearOwnedProperties();
|
||||
forgetAnchorConfig();
|
||||
resetFalconSingleton();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// 1. THE FINDING, on each of the two arming triggers.
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
/** An armed node over a registry with no binding proofs must refuse to start. */
|
||||
@Test
|
||||
public void armedOverAV1RegistryTheNodeREFUSESToStart() throws Exception {
|
||||
armWithForkBlock(writeRegistry("registru-v1.properties", false, false));
|
||||
|
||||
assertThatThrownBy(FalconSealSupport::instance)
|
||||
.describedAs(
|
||||
"a v1 registry decides attribution by whoever wrote the file; arming over it is "
|
||||
+ "permanent, because the anchor contract is immutable once written")
|
||||
.isInstanceOf(PqRegistryHash.RegistryConfigException.class)
|
||||
.hasMessageContaining("AERE-PQC-REG-ARM-02");
|
||||
}
|
||||
|
||||
/**
|
||||
* The same refusal when the node is armed through the CERTIFICATE ANCHOR and {@code
|
||||
* aere.falcon.forkBlock} is not set at all.
|
||||
*
|
||||
* <p>This is the case with no predecessor. AERE-PQC-REG-ARM-01 is raised by {@code
|
||||
* armingReadinessDiagnostic()}, whose first statement is to return when the fork block is unset,
|
||||
* so an anchor-armed node has never been asked ANY question about its registry's shape at startup.
|
||||
*/
|
||||
@Test
|
||||
public void armedThroughTheANCHORAloneTheNodeAlsoREFUSES() throws Exception {
|
||||
final Path v1 = writeRegistry("registru-v1.properties", false, false);
|
||||
System.setProperty("aere.falcon.registry", v1.toAbsolutePath().toString());
|
||||
armWithAnchorOnly();
|
||||
|
||||
assertThat(System.getProperty("aere.falcon.forkBlock"))
|
||||
.describedAs("this test is only worth something while the fork block is genuinely unset")
|
||||
.isNull();
|
||||
assertThatThrownBy(FalconSealSupport::instance)
|
||||
.isInstanceOf(PqRegistryHash.RegistryConfigException.class)
|
||||
.hasMessageContaining("AERE-PQC-REG-ARM-02");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// 2. THE POSITIVE CONTROLS. Without these the refusals above could be a load bug.
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The same node, the same arming, over a registry whose every row carries a Falcon possession
|
||||
* proof and an ECDSA claim signed by that row's own validator key, STARTS - and loads.
|
||||
*/
|
||||
@Test
|
||||
public void armedOverAV2RegistryTheNodeSTARTS() throws Exception {
|
||||
armWithForkBlock(writeRegistry("registru-v2.properties", true, true));
|
||||
|
||||
assertThatCode(FalconSealSupport::instance)
|
||||
.describedAs(
|
||||
"POSITIVE CONTROL: the gate can be green. A refusal that no correct input can pass is "
|
||||
+ "an outage wearing a security message")
|
||||
.doesNotThrowAnyException();
|
||||
assertThat(FalconSealSupport.instance().registrySize())
|
||||
.describedAs("and it must really have loaded the file, not merely declined to throw")
|
||||
.isEqualTo(N);
|
||||
}
|
||||
|
||||
/** The same, armed through the anchor alone. */
|
||||
@Test
|
||||
public void armedThroughTheANCHORAloneOverAV2RegistryTheNodeSTARTS() throws Exception {
|
||||
final Path v2 = writeRegistry("registru-v2.properties", true, true);
|
||||
System.setProperty("aere.falcon.registry", v2.toAbsolutePath().toString());
|
||||
armWithAnchorOnly();
|
||||
|
||||
assertThatCode(FalconSealSupport::instance).doesNotThrowAnyException();
|
||||
assertThat(FalconSealSupport.instance().registrySize()).isEqualTo(N);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// 3. THE BOUNDARY. A node that arms NOTHING must be untouched by any of this.
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* THE GUARANTEE FOR CHAIN 2800 AS IT STANDS: a node with no {@code aere.pq.*} property and no
|
||||
* {@code aere.falcon.forkBlock} starts over the very same v1 file that is refused when armed.
|
||||
*
|
||||
* <p>The assertion that carries the weight is not the "starts" - it is the property sweep. A test
|
||||
* that only asserted "does not throw" would keep passing if a later edit made the guard read some
|
||||
* other property that happened to be set in this JVM. The sweep states the precondition as a
|
||||
* measurement: at the moment the constructor runs, NO system property beginning with {@code
|
||||
* aere.pq.} exists, and neither does the fork block.
|
||||
*/
|
||||
@Test
|
||||
public void withNothingArmedTheGateIsInertOverTheSameV1Registry() throws Exception {
|
||||
final Path v1 = writeRegistry("registru-v1.properties", false, false);
|
||||
System.setProperty("aere.falcon.registry", v1.toAbsolutePath().toString());
|
||||
|
||||
assertThat(systemPropertiesStartingWith("aere.pq."))
|
||||
.describedAs("the precondition of this test, measured rather than assumed")
|
||||
.isEmpty();
|
||||
assertThat(System.getProperty("aere.falcon.forkBlock")).isNull();
|
||||
|
||||
assertThatCode(FalconSealSupport::instance)
|
||||
.describedAs(
|
||||
"the same file that is refused when armed is accepted when nothing is armed, so the "
|
||||
+ "trigger is ARMING and not the file")
|
||||
.doesNotThrowAnyException();
|
||||
assertThat(FalconSealSupport.instance().registrySize())
|
||||
.describedAs("and an unarmed node's registry is loaded exactly as it was before D-146")
|
||||
.isEqualTo(N);
|
||||
}
|
||||
|
||||
/**
|
||||
* The same boundary with NO registry configured either, which is a node holding nothing at all -
|
||||
* the shape of a fresh box joining the fleet before any key ceremony.
|
||||
*/
|
||||
@Test
|
||||
public void aNodeWithNoFalconConfigurationAtAllStarts() {
|
||||
assertThat(systemPropertiesStartingWith("aere.pq.")).isEmpty();
|
||||
assertThat(System.getProperty("aere.falcon.registry")).isNull();
|
||||
|
||||
assertThatCode(FalconSealSupport::instance).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
/**
|
||||
* An ARMED node with no registry file at all is deliberately NOT this guard's business, and this
|
||||
* test is what stops that from being a silent decision.
|
||||
*
|
||||
* <p>D-146 is mis-ATTRIBUTION, which needs rows; an empty registry credits nobody. The condition
|
||||
* is owned by AERE-PQC-CFG-UNSAFE-08 when the threshold is positive, and MEASURED here: with a
|
||||
* threshold of zero, which is the warm-up regime the fleet is meant to arm INTO, the node starts.
|
||||
* An earlier revision of this guard refused here, and the cost was exactly that - the intended
|
||||
* activation procedure became unstartable.
|
||||
*/
|
||||
@Test
|
||||
public void armedWithNoRegistryAtAllAndAZeroThresholdTheNodeStarts() {
|
||||
System.setProperty(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, Long.toString(FORK));
|
||||
System.setProperty(PqAnchorConfig.PROPERTY_CHAIN_ID, Long.toString(CHAIN_ID));
|
||||
System.setProperty(PqAnchorConfig.PROPERTY_MIN_SEALS, FORK + ":0");
|
||||
System.setProperty("aere.falcon.validatorCount", Integer.toString(N));
|
||||
System.setProperty("aere.falcon.testnetAllowSmallFleet", "true");
|
||||
|
||||
assertThatCode(FalconSealSupport::instance)
|
||||
.describedAs("K=0 over an empty registry is the warm-up regime, not a D-146 defect")
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// 4. HALF A v2 REGISTRY IS NOT A v2 REGISTRY.
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A row that carries a Falcon possession proof and no ECDSA claim proves that SOMEBODY holds the
|
||||
* key, and says nothing about which validator asked for it - which is the whole of D-146.
|
||||
*
|
||||
* <p>MEASURED, and the assertion was CHANGED to match the measurement rather than the other way
|
||||
* round. The expectation written first was AERE-PQC-REG-ARM-02. What actually happens is a refusal
|
||||
* one step EARLIER, at load, with AERE-PQC-REG-LOAD-21, because the loader counts proofs against
|
||||
* claims and refuses a half-bound file before the arming gate ever sees it. That is the stronger
|
||||
* of the two refusals - it holds whether or not the node is armed - so this is asserted on the
|
||||
* code that actually fires.
|
||||
*/
|
||||
@Test
|
||||
public void possessionWithoutAClaimIsRefusedEarlierStillAtLoad() throws Exception {
|
||||
armWithForkBlock(writeRegistry("registru-doar-posesie.properties", true, false));
|
||||
|
||||
assertThatThrownBy(FalconSealSupport::instance)
|
||||
.describedAs(
|
||||
"the attacker is the key holder, so a genuine possession proof over a lying row is "
|
||||
+ "genuinely produceable; only the validator's own signature closes it")
|
||||
.isInstanceOf(PqRegistryHash.RegistryConfigException.class)
|
||||
.hasMessageContaining("AERE-PQC-REG-LOAD-21");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// Helpers.
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
/** Arm through {@code aere.falcon.forkBlock}, the trigger AERE-PQC-REG-ARM-01 also watches. */
|
||||
private void armWithForkBlock(final Path registry) {
|
||||
System.setProperty("aere.falcon.registry", registry.toAbsolutePath().toString());
|
||||
System.setProperty("aere.falcon.forkBlock", Long.toString(FORK));
|
||||
System.setProperty("aere.falcon.attachBlock", Long.toString(ATTACH));
|
||||
System.setProperty("aere.falcon.validatorCount", Integer.toString(N));
|
||||
// N=4 is below the blocking minimum; this fixture is an isolated network and says so with the
|
||||
// switch the codebase already uses for exactly that, rather than by pretending to be seven.
|
||||
System.setProperty("aere.falcon.testnetAllowSmallFleet", "true");
|
||||
}
|
||||
|
||||
/**
|
||||
* Arm through the CERTIFICATE ANCHOR only, leaving {@code aere.falcon.forkBlock} unset. The
|
||||
* threshold is 2, which {@code worstCaseKeyedSigners(4, 4)} = 3 guarantees, so the D-078 guard
|
||||
* next door stays silent and cannot be mistaken for this one.
|
||||
*/
|
||||
private void armWithAnchorOnly() {
|
||||
System.setProperty(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, Long.toString(FORK));
|
||||
System.setProperty(PqAnchorConfig.PROPERTY_CHAIN_ID, Long.toString(CHAIN_ID));
|
||||
System.setProperty(PqAnchorConfig.PROPERTY_MIN_SEALS, FORK + ":0," + (FORK + 10L) + ":2");
|
||||
System.setProperty("aere.falcon.validatorCount", Integer.toString(N));
|
||||
System.setProperty("aere.falcon.testnetAllowSmallFleet", "true");
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a registry in the legacy properties form. {@code withPossession} and {@code withClaim} are
|
||||
* separate so that the half-bound case can be built, which is the one the loader must refuse.
|
||||
*/
|
||||
private Path writeRegistry(
|
||||
final String name, final boolean withPossession, final boolean withClaim) throws Exception {
|
||||
final StringBuilder b = new StringBuilder();
|
||||
if (withPossession || withClaim) {
|
||||
b.append("formatVersion=").append(PqRegistryBinding.FORMAT_VERSION).append('\n');
|
||||
b.append("chainId=").append(CHAIN_ID).append('\n');
|
||||
b.append("bindHeight=").append(BIND_HEIGHT).append('\n');
|
||||
}
|
||||
b.append("count=").append(N).append('\n');
|
||||
for (int i = 0; i < N; i++) {
|
||||
b.append(i).append('=').append(unprefixed(PqV2Fixture.publicKey(i))).append('\n');
|
||||
b.append(i)
|
||||
.append(".addr=")
|
||||
.append(unprefixed(PqV2Fixture.address(i).getBytes().toArray()))
|
||||
.append('\n');
|
||||
if (withPossession) {
|
||||
b.append(i)
|
||||
.append(".pop=")
|
||||
.append(strip(PqV2Fixture.popHex(CHAIN_ID, BIND_HEIGHT, N, i)))
|
||||
.append('\n');
|
||||
}
|
||||
if (withClaim) {
|
||||
b.append(i)
|
||||
.append(".claim=")
|
||||
.append(strip(PqV2Fixture.claimHex(CHAIN_ID, BIND_HEIGHT, N, i)))
|
||||
.append('\n');
|
||||
}
|
||||
}
|
||||
final Path f = tmp.resolve(name);
|
||||
Files.writeString(f, b.toString(), StandardCharsets.UTF_8);
|
||||
return f;
|
||||
}
|
||||
|
||||
private static String unprefixed(final byte[] b) {
|
||||
return Bytes.wrap(b).toUnprefixedHexString();
|
||||
}
|
||||
|
||||
private static String strip(final String hex) {
|
||||
return hex.startsWith("0x") ? hex.substring(2) : hex;
|
||||
}
|
||||
|
||||
/** Every system property name with the given prefix, so a precondition can be MEASURED. */
|
||||
private static List<String> systemPropertiesStartingWith(final String prefix) {
|
||||
final Properties p = System.getProperties();
|
||||
return p.stringPropertyNames().stream()
|
||||
.filter(n -> n.startsWith(prefix))
|
||||
.sorted()
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private static void clearOwnedProperties() {
|
||||
for (final String p : OWNED_PROPERTIES) {
|
||||
System.clearProperty(p);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force the anchor configuration to be re-read from system properties.
|
||||
*
|
||||
* <p>MEASURED 2026-08-06, and it is the reason this method exists rather than being assumed
|
||||
* unnecessary. {@code PqAnchorProducer.config()} memoises the first configuration it ever builds,
|
||||
* for the life of the JVM. That is CORRECT in production - a node is one JVM with one set of
|
||||
* properties, and a configuration that could change underneath the consensus path would be worse
|
||||
* than one that cannot. In a test JVM shared by every class in this module it means an anchor
|
||||
* armed by an earlier test is still armed here, and {@link
|
||||
* #withNothingArmedTheGateIsInertOverTheSameV1Registry} failed exactly that way before this call
|
||||
* was added: the property sweep found no {@code aere.pq.*} and the node still refused, because
|
||||
* the memo held another class's anchor.
|
||||
*/
|
||||
private static void forgetAnchorConfig() {
|
||||
org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer.useConfigForTesting(
|
||||
null);
|
||||
}
|
||||
|
||||
private static void resetFalconSingleton() throws Exception {
|
||||
final Field f = FalconSealSupport.class.getDeclaredField("instance");
|
||||
f.setAccessible(true);
|
||||
f.set(null, null);
|
||||
}
|
||||
}
|
||||
@ -1,466 +0,0 @@
|
||||
/*
|
||||
* Copyright contributors to Besu / AERE Network.
|
||||
*
|
||||
* 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.assertThatCode;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.mockito.Mockito.withSettings;
|
||||
|
||||
import org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer;
|
||||
import org.hyperledger.besu.consensus.common.validator.ValidatorProvider;
|
||||
import org.hyperledger.besu.datatypes.Address;
|
||||
import org.hyperledger.besu.ethereum.ProtocolContext;
|
||||
import org.hyperledger.besu.ethereum.core.BlockHeader;
|
||||
import org.hyperledger.besu.ethereum.core.BlockHeaderTestFixture;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.InvocationHandler;
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
import java.util.Properties;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.apache.logging.log4j.Level;
|
||||
import org.apache.tuweni.bytes.Bytes32;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.quality.Strictness;
|
||||
|
||||
/**
|
||||
* THE COMPATIBILITY PROPERTY, which is the one that decides whether any of this can be shipped.
|
||||
*
|
||||
* <p>The three anchor patches plus the D-146 arming gate are meant to travel onto the seven live
|
||||
* boxes BEFORE the activation height, so that the fleet is already running the binary when the
|
||||
* height arrives and activation is a restart-free event. That plan is only sound if a node holding
|
||||
* this binary and NO {@code aere.pq.*} configuration is indistinguishable from one holding the
|
||||
* binary it replaces: it must start, it must produce blocks, and it must not say a word about an
|
||||
* anchor that is not armed. If that property is lost, the whole package is unusable regardless of
|
||||
* how correct the anchor logic is, because it could not be staged.
|
||||
*
|
||||
* <p>WHY THE SILENCE IS MEASURED AND NOT ASSUMED. "It returns early, so it cannot log" is a reading
|
||||
* of the code, not a measurement, and the integrated tree has four patches whose log sites nobody
|
||||
* has looked at together. Here the actual Log4j2 pipeline is tapped and the lines are counted.
|
||||
*
|
||||
* <p>WHY {@link #positiveControlTheCaptorSEESTheAnchorWhenItISArmed} is not optional. A captor that
|
||||
* attaches to nothing reports silence forever, and every assertion in {@link
|
||||
* #withNoAerePropertiesTheProposerProducesABlockAndSaysNOTHING} would pass against a broken tap.
|
||||
* The positive control arms the anchor and requires that the SAME captor, in the same JVM, sees the
|
||||
* producer's activation line. Without it this class would be a proof that cannot go red.
|
||||
*
|
||||
* <p>WHY THE CAPTOR IS BUILT BY REFLECTION. {@code log4j-core}, which owns the appender API, is on
|
||||
* this module's RUNTIME test classpath but not its COMPILE one - measured, not assumed. Adding it as
|
||||
* a compile dependency would put a build file into the AERE overlay, which until now is Java only.
|
||||
* Reflection keeps the overlay unchanged, and the positive control is what makes it safe: if any of
|
||||
* the reflective steps silently failed, the captor would see nothing and the positive control would
|
||||
* be the test that fails.
|
||||
*
|
||||
* <p>NOT MEASURED, and written rather than implied: nothing is deployed and no node is started. That
|
||||
* an unarmed node on one of the seven real boxes behaves this way over a real chain, at 523 ms
|
||||
* blocks, alongside a peer that IS armed, is NOT MEASURED and needs the rehearsal network.
|
||||
*/
|
||||
public class D147InertBinaryTest {
|
||||
|
||||
/**
|
||||
* Loggers that exist ONLY because of the anchor work, so any line from them on an unarmed node is
|
||||
* by itself a finding.
|
||||
*
|
||||
* <p>{@code FalconSealSupport} is deliberately NOT here even though it is the loudest of them.
|
||||
* It predates the anchor and legitimately says one thing at startup; listing it would make the
|
||||
* filter report a four-year-old INFO line as new anchor chatter. Its armed messages are caught by
|
||||
* {@link #ANCHOR_WORDS} instead, which keys on what the line SAYS rather than who said it.
|
||||
*/
|
||||
private static final List<String> ANCHOR_LOGGERS =
|
||||
List.of(
|
||||
"org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer",
|
||||
"org.hyperledger.besu.consensus.common.bft.PqAnchorConfig",
|
||||
"org.hyperledger.besu.consensus.common.bft.PqSealStore",
|
||||
"org.hyperledger.besu.consensus.common.bft.PqRegistryBinding");
|
||||
|
||||
/**
|
||||
* Words that name the ANCHOR - the capability these patches add - in a message body, whichever
|
||||
* logger emitted it. Deliberately narrower than "anything mentioning Falcon": the Falcon registry
|
||||
* predates all of this, and a filter that cannot tell the new surface from the old one would call
|
||||
* a pre-existing line a regression.
|
||||
*/
|
||||
private static final List<String> ANCHOR_WORDS =
|
||||
List.of("pq-anchor", "anchor", "aere-pqc", "aere pqc d2");
|
||||
|
||||
/**
|
||||
* THE ONE LINE an unarmed node has always written, quoted so that any NEW startup chatter turns
|
||||
* this class red.
|
||||
*
|
||||
* <p>MEASURED 2026-08-06, and it is the reason this constant exists rather than an {@code
|
||||
* isEmpty()} on everything. The first shape of this test asserted total silence and went red on
|
||||
* this line. It is not a regression: {@code git log -S} places it in commit 307fd0d0, the snapshot
|
||||
* of everything built between 14 June and 2 August, so it predates all three anchor patches and
|
||||
* the arming gate. It is {@code LOG.info} and it says the node has no Falcon registry, which is
|
||||
* true and was equally true of the binary being replaced.
|
||||
*
|
||||
* <p>So the property that is actually worth defending is not "says nothing" - that was never true
|
||||
* - but "says nothing NEW, and nothing about the anchor". Pinning the exact text is what makes the
|
||||
* second half enforceable: a fourth patch that adds one more startup line has to come here and
|
||||
* change this constant deliberately.
|
||||
*/
|
||||
private static final String THE_ONE_PRE_EXISTING_LINE =
|
||||
"AERE PQC: no Falcon registry configured "
|
||||
+ "(aere.falcon.genesis/aere.falcon.manifest/aere.falcon.registry); "
|
||||
+ "hybrid seal verification will be a no-op.";
|
||||
|
||||
private static final long CHAIN_ID = 220_878L;
|
||||
|
||||
private static final long H = 4_000L;
|
||||
|
||||
/**
|
||||
* Every property this class may touch. The unarmed test does not rely on this list - it sweeps the
|
||||
* whole property table - but the armed one must put back exactly what it took.
|
||||
*/
|
||||
private static final List<String> OWNED_PROPERTIES =
|
||||
List.of(
|
||||
"aere.falcon.registry",
|
||||
"aere.falcon.forkBlock",
|
||||
"aere.falcon.attachBlock",
|
||||
"aere.falcon.validatorCount",
|
||||
"aere.falcon.testnetAllowSmallFleet",
|
||||
PqAnchorConfig.PROPERTY_ANCHOR_BLOCK,
|
||||
PqAnchorConfig.PROPERTY_CHAIN_ID,
|
||||
PqAnchorConfig.PROPERTY_MIN_SEALS);
|
||||
|
||||
@BeforeEach
|
||||
public void setUp() throws Exception {
|
||||
clearOwnedProperties();
|
||||
forgetAnchorConfig();
|
||||
resetFalconSingleton();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
public void tearDown() throws Exception {
|
||||
clearOwnedProperties();
|
||||
forgetAnchorConfig();
|
||||
resetFalconSingleton();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// THE PROPERTY.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A node carrying the integrated binary and no anchor configuration starts, produces a block, and
|
||||
* logs nothing about the anchor.
|
||||
*
|
||||
* <p>The block-production half is asserted on OBJECT IDENTITY, not equality. {@code
|
||||
* PqAnchorProducer.apply} returns its argument unchanged at the first branch when the anchor is
|
||||
* not active; an equal-but-rebuilt {@code BftExtraData} would mean the producer had walked the
|
||||
* certificate path and merely arrived back at the same value, which is a different and much
|
||||
* weaker statement.
|
||||
*/
|
||||
@Test
|
||||
public void withNoAerePropertiesTheProposerProducesABlockAndSaysNOTHING() throws Exception {
|
||||
// The precondition is MEASURED over the whole property table rather than trusted to the
|
||||
// teardown of whatever test ran before this one in this JVM.
|
||||
assertThat(systemPropertiesStartingWith("aere."))
|
||||
.describedAs("the precondition of this test, measured rather than assumed")
|
||||
.isEmpty();
|
||||
|
||||
final LogCaptor captor = LogCaptor.attach();
|
||||
final BftExtraData produced;
|
||||
final BftExtraData base = plainExtraData();
|
||||
try {
|
||||
assertThatCode(FalconSealSupport::instance)
|
||||
.describedAs("a box with no key ceremony behind it must still come up")
|
||||
.doesNotThrowAnyException();
|
||||
|
||||
final BlockHeader parent = new BlockHeaderTestFixture().number(H + 500L).buildHeader();
|
||||
produced = PqAnchorProducer.apply(base, parent, contextWith(List.of()));
|
||||
} finally {
|
||||
captor.detach();
|
||||
}
|
||||
|
||||
assertThat(produced)
|
||||
.describedAs(
|
||||
"the unarmed producer must hand back the very object it was given; an equal copy would "
|
||||
+ "mean it had walked the certificate path")
|
||||
.isSameAs(base);
|
||||
|
||||
assertThat(PqAnchorProducer.config().everActive())
|
||||
.describedAs("and it must consider itself never-active, not merely inactive right now")
|
||||
.isFalse();
|
||||
|
||||
assertThat(captor.anchorLines())
|
||||
.describedAs(
|
||||
"an operator staging this binary before the height must see NOTHING about the anchor; "
|
||||
+ "%d line(s) in total were seen, so the captor was live",
|
||||
captor.total())
|
||||
.isEmpty();
|
||||
|
||||
// And nothing NEW of any kind. This is the half that catches a future patch adding chatter.
|
||||
assertThat(captor.aereLines())
|
||||
.describedAs(
|
||||
"the whole AERE output of an unarmed node, pinned: exactly the one INFO line that "
|
||||
+ "predates these patches (commit 307fd0d0). A new line here is a staging "
|
||||
+ "regression even when it is harmless, because it changes what the fleet prints "
|
||||
+ "on a restart that is supposed to be a no-op.")
|
||||
.containsExactly(THE_ONE_PRE_EXISTING_LINE);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// THE POSITIVE CONTROL, without which the test above proves nothing.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The same captor, the same JVM, the same loggers - with the anchor armed. If this does not see a
|
||||
* line, the silence measured above is the silence of a broken tap and means nothing.
|
||||
*
|
||||
* <p>The line chosen is the producer's own activation notice, emitted from {@code
|
||||
* PqAnchorProducer.config()} the first time a configuration is built. Its once-per-JVM latch is
|
||||
* reset by {@code useConfigForTesting(null)}, which is why {@link #forgetAnchorConfig()} runs
|
||||
* before every test in this class.
|
||||
*/
|
||||
@Test
|
||||
public void positiveControlTheCaptorSEESTheAnchorWhenItISArmed() throws Exception {
|
||||
System.setProperty(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, Long.toString(H));
|
||||
System.setProperty(PqAnchorConfig.PROPERTY_CHAIN_ID, Long.toString(CHAIN_ID));
|
||||
// A whole-zero schedule is refused since the D-147 floor (armed anchor, no signature
|
||||
// requirement, for ever). The warm-up step at H stays 0; the rise satisfies the floor.
|
||||
System.setProperty(PqAnchorConfig.PROPERTY_MIN_SEALS, H + ":0," + (H + 21_600L) + ":3");
|
||||
|
||||
final LogCaptor captor = LogCaptor.attach();
|
||||
try {
|
||||
PqAnchorProducer.config();
|
||||
} finally {
|
||||
captor.detach();
|
||||
}
|
||||
|
||||
assertThat(captor.anchorLines())
|
||||
.describedAs(
|
||||
"the captor must be able to hear the anchor, or the silence next door is worthless")
|
||||
.isNotEmpty();
|
||||
assertThat(String.join("\n", captor.anchorLines())).contains("producer armed");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Helpers.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/** Extra data with no anchor digest, i.e. exactly what a pre-fork proposer builds. */
|
||||
private static BftExtraData plainExtraData() {
|
||||
return new BftExtraData(
|
||||
Bytes32.ZERO,
|
||||
Collections.emptyList(),
|
||||
Optional.empty(),
|
||||
0,
|
||||
Collections.emptyList(),
|
||||
Collections.emptyList());
|
||||
}
|
||||
|
||||
private static ProtocolContext contextWith(final Collection<Address> validators) {
|
||||
final ValidatorProvider validatorProvider =
|
||||
mock(ValidatorProvider.class, withSettings().strictness(Strictness.LENIENT));
|
||||
when(validatorProvider.getValidatorsForBlock(any())).thenReturn(validators);
|
||||
when(validatorProvider.getValidatorsAfterBlock(any())).thenReturn(validators);
|
||||
final BftContext bftContext =
|
||||
mock(BftContext.class, withSettings().strictness(Strictness.LENIENT));
|
||||
when(bftContext.getValidatorProvider()).thenReturn(validatorProvider);
|
||||
when(bftContext.as(any())).thenReturn(bftContext);
|
||||
return new ProtocolContext.Builder().withConsensusContext(bftContext).build();
|
||||
}
|
||||
|
||||
private static List<String> systemPropertiesStartingWith(final String prefix) {
|
||||
final Properties p = System.getProperties();
|
||||
return p.stringPropertyNames().stream()
|
||||
.filter(n -> n.startsWith(prefix))
|
||||
.sorted()
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private static void clearOwnedProperties() {
|
||||
for (final String p : OWNED_PROPERTIES) {
|
||||
System.clearProperty(p);
|
||||
}
|
||||
}
|
||||
|
||||
private static void forgetAnchorConfig() {
|
||||
PqAnchorProducer.useConfigForTesting(null);
|
||||
}
|
||||
|
||||
private static void resetFalconSingleton() throws Exception {
|
||||
final Field f = FalconSealSupport.class.getDeclaredField("instance");
|
||||
f.setAccessible(true);
|
||||
f.set(null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* A Log4j2 appender built as a dynamic proxy and attached to the root logger, so that this module
|
||||
* can read the real logging pipeline without taking a compile dependency on {@code log4j-core}.
|
||||
*
|
||||
* <p>{@link #attach()} throws if any reflective step fails. It does NOT fall back to a silent
|
||||
* captor: a captor that quietly captures nothing is precisely the failure this class is written to
|
||||
* exclude.
|
||||
*/
|
||||
private static final class LogCaptor {
|
||||
|
||||
private final List<String> lines = Collections.synchronizedList(new ArrayList<>());
|
||||
private final Object rootLoggerConfig;
|
||||
private final Object loggerContext;
|
||||
private final Level priorLevel;
|
||||
|
||||
private LogCaptor(
|
||||
final Object rootLoggerConfig, final Object loggerContext, final Level priorLevel) {
|
||||
this.rootLoggerConfig = rootLoggerConfig;
|
||||
this.loggerContext = loggerContext;
|
||||
this.priorLevel = priorLevel;
|
||||
}
|
||||
|
||||
static LogCaptor attach() throws Exception {
|
||||
final Class<?> appenderCls = Class.forName("org.apache.logging.log4j.core.Appender");
|
||||
final Class<?> eventCls = Class.forName("org.apache.logging.log4j.core.LogEvent");
|
||||
final Class<?> configCls = Class.forName("org.apache.logging.log4j.core.config.Configuration");
|
||||
final Class<?> loggerConfigCls =
|
||||
Class.forName("org.apache.logging.log4j.core.config.LoggerConfig");
|
||||
final Class<?> filterCls = Class.forName("org.apache.logging.log4j.core.Filter");
|
||||
final Class<?> ctxCls = Class.forName("org.apache.logging.log4j.core.LoggerContext");
|
||||
final Class<?> stateCls = Class.forName("org.apache.logging.log4j.core.LifeCycle$State");
|
||||
|
||||
// LogManager is reached reflectively as well, not out of symmetry but because the build bans
|
||||
// the symbol: [BannedMethod] "Do not use org.apache.logging.log4j.LogManager, use
|
||||
// org.slf4j.LoggerFactory instead", and the ban is right for production code. A test that
|
||||
// needs to inspect the logging pipeline itself is the one place it cannot be honoured, and
|
||||
// going through the SLF4J facade cannot reach the appender list at all.
|
||||
final Class<?> logManagerCls = Class.forName("org.apache.logging.log4j.LogManager");
|
||||
final Object ctx =
|
||||
logManagerCls.getMethod("getContext", boolean.class).invoke(null, Boolean.FALSE);
|
||||
if (!ctxCls.isInstance(ctx)) {
|
||||
throw new IllegalStateException(
|
||||
"the SLF4J binding in this JVM is not log4j-core, so the log cannot be tapped: "
|
||||
+ ctx.getClass().getName());
|
||||
}
|
||||
final Object configuration = ctxCls.getMethod("getConfiguration").invoke(ctx);
|
||||
final Object rootLoggerConfig = configCls.getMethod("getRootLogger").invoke(configuration);
|
||||
|
||||
final List<String> sink = Collections.synchronizedList(new ArrayList<>());
|
||||
final Method getMessage = eventCls.getMethod("getMessage");
|
||||
final Method getLoggerName = eventCls.getMethod("getLoggerName");
|
||||
Object startedState = null;
|
||||
for (final Object c : stateCls.getEnumConstants()) {
|
||||
if ("STARTED".equals(((Enum<?>) c).name())) {
|
||||
startedState = c;
|
||||
}
|
||||
}
|
||||
final Object started = startedState;
|
||||
|
||||
final InvocationHandler handler =
|
||||
(proxy, method, args) -> {
|
||||
switch (method.getName()) {
|
||||
case "append":
|
||||
final Object event = args[0];
|
||||
final Object msg = getMessage.invoke(event);
|
||||
final String text =
|
||||
(String) msg.getClass().getMethod("getFormattedMessage").invoke(msg);
|
||||
sink.add(getLoggerName.invoke(event) + " | " + text);
|
||||
return null;
|
||||
case "getName":
|
||||
return "aere-d147-captor";
|
||||
case "isStarted":
|
||||
return Boolean.TRUE;
|
||||
case "isStopped":
|
||||
return Boolean.FALSE;
|
||||
case "getState":
|
||||
return started;
|
||||
case "ignoreExceptions":
|
||||
return Boolean.TRUE;
|
||||
case "equals":
|
||||
return proxy == args[0];
|
||||
case "hashCode":
|
||||
return System.identityHashCode(proxy);
|
||||
case "toString":
|
||||
return "aere-d147-captor";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
final Object appender =
|
||||
Proxy.newProxyInstance(
|
||||
D147InertBinaryTest.class.getClassLoader(), new Class<?>[] {appenderCls}, handler);
|
||||
|
||||
final Level prior = (Level) loggerConfigCls.getMethod("getLevel").invoke(rootLoggerConfig);
|
||||
loggerConfigCls
|
||||
.getMethod("addAppender", appenderCls, Level.class, filterCls)
|
||||
.invoke(rootLoggerConfig, appender, Level.ALL, null);
|
||||
loggerConfigCls.getMethod("setLevel", Level.class).invoke(rootLoggerConfig, Level.ALL);
|
||||
ctxCls.getMethod("updateLoggers").invoke(ctx);
|
||||
|
||||
final LogCaptor captor = new LogCaptor(rootLoggerConfig, ctx, prior);
|
||||
captor.bind(sink);
|
||||
return captor;
|
||||
}
|
||||
|
||||
/** The proxy writes into its own list; this keeps a single reading surface. */
|
||||
private List<String> bound;
|
||||
|
||||
private void bind(final List<String> sink) {
|
||||
this.bound = sink;
|
||||
}
|
||||
|
||||
void detach() throws Exception {
|
||||
final Class<?> loggerConfigCls =
|
||||
Class.forName("org.apache.logging.log4j.core.config.LoggerConfig");
|
||||
final Class<?> ctxCls = Class.forName("org.apache.logging.log4j.core.LoggerContext");
|
||||
loggerConfigCls
|
||||
.getMethod("removeAppender", String.class)
|
||||
.invoke(rootLoggerConfig, "aere-d147-captor");
|
||||
loggerConfigCls.getMethod("setLevel", Level.class).invoke(rootLoggerConfig, priorLevel);
|
||||
ctxCls.getMethod("updateLoggers").invoke(loggerContext);
|
||||
lines.addAll(bound);
|
||||
}
|
||||
|
||||
int total() {
|
||||
return lines.size();
|
||||
}
|
||||
|
||||
/** Every captured line that names the ANCHOR, by logger or by wording. */
|
||||
List<String> anchorLines() {
|
||||
return lines.stream()
|
||||
.filter(
|
||||
l -> {
|
||||
final String lower = l.toLowerCase(Locale.ROOT);
|
||||
return ANCHOR_LOGGERS.contains(loggerOf(l))
|
||||
|| ANCHOR_WORDS.stream().anyMatch(lower::contains);
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/** Every captured message body that AERE code emitted, logger prefix stripped. */
|
||||
List<String> aereLines() {
|
||||
return lines.stream()
|
||||
.filter(l -> loggerOf(l).contains(".bft") || l.contains("AERE"))
|
||||
.map(l -> l.substring(l.indexOf(" | ") + 3))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private static String loggerOf(final String line) {
|
||||
final int i = line.indexOf(" | ");
|
||||
return i < 0 ? "" : line.substring(0, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,97 +0,0 @@
|
||||
/*
|
||||
* Copyright contributors to the AERE Network.
|
||||
*
|
||||
* 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.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* D-177: the operator-facing switches must not name an algorithm. Every legacy {@code
|
||||
* aere.falcon.*} property has an algorithm-neutral twin {@code aere.pq.sig.*}, read FIRST, with
|
||||
* the legacy spelling kept as a working fallback, and a loud refusal when the two spellings
|
||||
* disagree - the 2026-08-09 lost-fork incident is exactly what a silent preference would invite.
|
||||
*
|
||||
* <p>These tests exercise the single choke point every configured value passes through
|
||||
* ({@link FalconSealSupport#resolve}), so the four behaviours are proven once for all sixteen
|
||||
* switches instead of sixteen times over.
|
||||
*/
|
||||
class D177NeutralNamesTest {
|
||||
|
||||
private static final String LEGACY = "aere.falcon.validatorCount";
|
||||
private static final String NEUTRAL = "aere.pq.sig.validatorCount";
|
||||
private static final String ENV = "AERE_FALCON_VALIDATOR_COUNT";
|
||||
|
||||
@AfterEach
|
||||
void clear() {
|
||||
System.clearProperty(LEGACY);
|
||||
System.clearProperty(NEUTRAL);
|
||||
}
|
||||
|
||||
@Test
|
||||
void neutralNameAloneIsRead() {
|
||||
System.setProperty(NEUTRAL, "9");
|
||||
assertThat(FalconSealSupport.resolve(LEGACY, ENV)).isEqualTo("9");
|
||||
}
|
||||
|
||||
@Test
|
||||
void legacyNameAloneStillWorks() {
|
||||
System.setProperty(LEGACY, "7");
|
||||
assertThat(FalconSealSupport.resolve(LEGACY, ENV)).isEqualTo("7");
|
||||
}
|
||||
|
||||
@Test
|
||||
void bothNamesSameValueIsAMigrationWindow() {
|
||||
System.setProperty(NEUTRAL, "9");
|
||||
System.setProperty(LEGACY, "9");
|
||||
assertThat(FalconSealSupport.resolve(LEGACY, ENV)).isEqualTo("9");
|
||||
}
|
||||
|
||||
@Test
|
||||
void bothNamesDifferentValuesRefuseLoudly() {
|
||||
System.setProperty(NEUTRAL, "9");
|
||||
System.setProperty(LEGACY, "7");
|
||||
assertThatThrownBy(() -> FalconSealSupport.resolve(LEGACY, ENV))
|
||||
.isInstanceOf(FalconSealSupport.ActivationConfigException.class)
|
||||
.hasMessageContaining("AERE-PQC-CFG-DUAL-NAME-01")
|
||||
.hasMessageContaining(NEUTRAL)
|
||||
.hasMessageContaining(LEGACY);
|
||||
}
|
||||
|
||||
@Test
|
||||
void neutralNameWinsWhenBothAreSemanticallyEqual() {
|
||||
// same text with different whitespace: trim makes them equal, and the neutral value is the
|
||||
// one returned, so new fleets can write only the neutral name with no surprise
|
||||
System.setProperty(NEUTRAL, " 9 ");
|
||||
System.setProperty(LEGACY, "9");
|
||||
assertThat(FalconSealSupport.resolve(LEGACY, ENV)).isEqualTo(" 9 ");
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonFalconPropertiesAreLeftUntouched() {
|
||||
// a property that does not start with aere.falcon. gets no twin: resolve stays exactly the
|
||||
// reader it was before for it
|
||||
System.setProperty("aere.pq.anchorBlock", "13014000");
|
||||
try {
|
||||
assertThat(FalconSealSupport.resolve("aere.pq.anchorBlock", "AERE_PQ_ANCHOR_BLOCK"))
|
||||
.isEqualTo("13014000");
|
||||
} finally {
|
||||
System.clearProperty("aere.pq.anchorBlock");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,386 +0,0 @@
|
||||
/*
|
||||
* Copyright contributors to Besu / AERE Network.
|
||||
*
|
||||
* 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 org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer;
|
||||
import org.hyperledger.besu.datatypes.Address;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.OptionalInt;
|
||||
|
||||
import org.apache.tuweni.bytes.Bytes;
|
||||
import org.apache.tuweni.bytes.Bytes32;
|
||||
import org.bouncycastle.crypto.digests.KeccakDigest;
|
||||
import org.bouncycastle.pqc.crypto.falcon.FalconPrivateKeyParameters;
|
||||
import org.bouncycastle.pqc.crypto.falcon.FalconSigner;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
/**
|
||||
* D2 HARDENING (b-v2). The repair of the repair: the caller's MOTIVE decides, not the height.
|
||||
*
|
||||
* <p>WHAT THE FIRST SHAPE DID, MEASURED AND NOT ARGUED. On 2026-08-06 hardening (b) refused every
|
||||
* unbound height at or above the arming height, deciding from the block NUMBER alone. Run against
|
||||
* the suite that gave 588 tests and 0 failures on a clean tree, it gave 597 tests and 6 failures:
|
||||
* five in {@code D141SealPersistenceTest} and one in {@code D078ValidatorSetChangeTest}. Both
|
||||
* classes work on THIS NODE'S OWN head - restarting and re-reading its own seal file, and proposing
|
||||
* on top of its own head - and in all six the number handed to the guard was 1030 with an arming
|
||||
* height of 1000. A genuinely historical question, in the same process in the same second, hands
|
||||
* the guard exactly those numbers too. No arithmetic on the height separates them.
|
||||
*
|
||||
* <p>THE OPERATIONAL CONSEQUENCE, in the words of the D078 failure itself: {@code refusing to
|
||||
* propose on top of block 1030 because this node holds 0 valid eligible Falcon seal(s)}. The first
|
||||
* shape turned a defect that is invisible on a running fleet and fatal only to a node syncing later
|
||||
* into one that stops block production on all seven, in the minute the anchor is armed.
|
||||
*
|
||||
* <p>WHAT SEPARATES THEM IS WHO SUPPLIES THE SUBJECT, and that is known at every call site and was
|
||||
* being discarded at the interface boundary. So {@code PqSignerRegistry} now carries two named
|
||||
* pairs, and the compiler forces every call site to say which question it is asking. This class is
|
||||
* the proof that the two doors answer DIFFERENTLY at the SAME height, that the own-head door is not
|
||||
* a loophole, and that the history door still refuses.
|
||||
*
|
||||
* <p>THIS CLASS CANNOT GO GREEN BY ACCIDENT. Three of its tests fail if the own-head door is made
|
||||
* to refuse (which is the first shape restored), and three fail if the history door is made to
|
||||
* answer (which is the pre-2026-08-06 defect restored). The two plants are run in opposite
|
||||
* directions and both are recorded in the evidence directory.
|
||||
*/
|
||||
public class D2CallerIntentTest {
|
||||
|
||||
/** Anchor activation height H, matching the fixture the six failures ran under. */
|
||||
private static final long H = 1_000L;
|
||||
|
||||
/** Height from which the staged threshold is non-zero, i.e. the fully armed regime. */
|
||||
private static final long K_AT = H + 10L;
|
||||
|
||||
/**
|
||||
* The height the six failures actually presented to the guard: this node's own head, above the
|
||||
* arming height. Named for what it is, because the whole point is that the NUMBER is innocent.
|
||||
*/
|
||||
private static final long OWN_HEAD = 1_030L;
|
||||
|
||||
/** A height far above H, standing in for "a year of history above the arming height". */
|
||||
private static final long DEEP = K_AT + 5_000L;
|
||||
|
||||
private static final int N = 7;
|
||||
|
||||
private static final long CHAIN_ID = 220_878L;
|
||||
|
||||
@TempDir private Path tmp;
|
||||
|
||||
private final List<FalconPrivateKeyParameters> privateKeys = new ArrayList<>();
|
||||
private final List<Address> validators = new ArrayList<>();
|
||||
private Path genesisPath;
|
||||
|
||||
/** A fixed 32-byte message, standing in for M(parent) or a committed-seal hash. */
|
||||
private static final Bytes32 MESSAGE = Bytes32.fromHexString("0x" + "5a".repeat(32));
|
||||
|
||||
private Bytes sealByIndexZero;
|
||||
|
||||
@BeforeEach
|
||||
public void setUp() throws Exception {
|
||||
// AERE D-146 (2026-08-06): a v2, PROOF-BOUND registry. It used to be v1 with addresses spelled
|
||||
// 0xA00+i, which no secp256k1 key can sign for, so this fixture described a fleet that could
|
||||
// never satisfy AERE-PQC-REG-ARM-02 once that guard was wired. The registry is bound at H, the
|
||||
// height this fixture arms the anchor from.
|
||||
final KeccakDigest kd = new KeccakDigest(256);
|
||||
final StringBuilder manifest = new StringBuilder();
|
||||
manifest
|
||||
.append("{\"config\":{\"aereFalconRegistry\":{")
|
||||
.append(PqV2Fixture.manifestHeader(N, CHAIN_ID, H));
|
||||
for (int i = 0; i < N; i++) {
|
||||
privateKeys.add(PqV2Fixture.privateKey(i));
|
||||
validators.add(PqV2Fixture.address(i));
|
||||
final byte[] anchoredRow = PqV2Fixture.anchorPreimageRow(i);
|
||||
kd.update(anchoredRow, 0, anchoredRow.length);
|
||||
manifest.append(',').append(PqV2Fixture.manifestEntry(i, N, CHAIN_ID, H));
|
||||
}
|
||||
manifest
|
||||
.append("}},\"alloc\":{\"0000000000000000000000000000000000000fa1\":{\"storage\":{\"0x")
|
||||
.append("0".repeat(64))
|
||||
.append("\":\"0x");
|
||||
final byte[] anchoredHash = new byte[32];
|
||||
kd.doFinal(anchoredHash, 0);
|
||||
manifest.append(Bytes.wrap(anchoredHash).toUnprefixedHexString()).append("\"}}}}");
|
||||
|
||||
genesisPath = tmp.resolve("genesis-d2v2.json");
|
||||
Files.writeString(genesisPath, manifest.toString());
|
||||
System.setProperty("aere.falcon.genesis", genesisPath.toAbsolutePath().toString());
|
||||
|
||||
final FalconSigner signer = new FalconSigner();
|
||||
signer.init(true, privateKeys.get(0));
|
||||
sealByIndexZero = Bytes.wrap(signer.generateSignature(MESSAGE.toArray()));
|
||||
|
||||
resetFalconSingleton();
|
||||
// Exactly the montage the six failures ran under: armed at 1000, K staged at 1010, own head
|
||||
// 1030, and NO config.pqRegistryHash anywhere - the state of every node on chain 2800 today.
|
||||
PqAnchorProducer.useConfigForTesting(
|
||||
new PqAnchorConfig(CHAIN_ID, H, Map.of(H, 0, K_AT, 3), OptionalInt.empty(), false));
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
public void tearDown() throws Exception {
|
||||
System.clearProperty("aere.falcon.genesis");
|
||||
System.clearProperty("aere.pq.genesis");
|
||||
System.clearProperty(FalconSealSupport.PROPERTY_REGISTRY_HISTORY);
|
||||
resetFalconSingleton();
|
||||
PqAnchorProducer.useConfigForTesting(null);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// 0. The fixture. Without this a green run below could mean the registry never loaded at all.
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
public void baselineTheFixtureIsGenesisAnchoredAndTheSealIsGENUINE() {
|
||||
final FalconSealSupport pqc = FalconSealSupport.instance();
|
||||
assertThat(pqc.genesisAnchored())
|
||||
.describedAs("the fixture must load a GENESIS-ANCHORED registry, or nothing here means anything")
|
||||
.isTrue();
|
||||
assertThat(pqc.addressBound()).isTrue();
|
||||
assertThat(pqc.verify(0, MESSAGE, sealByIndexZero))
|
||||
.describedAs("the seal must be a REAL Falcon signature under the head registry")
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// 1. THE WHOLE REPAIR, IN ONE ASSERTION. Same height, same index, same signature, same instant.
|
||||
// Two answers, because two different questions were asked.
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
public void theSameHeightGivesTwoAnswersBecauseTheQUESTIONSDIFFER() {
|
||||
final FalconSealSupport pqc = FalconSealSupport.instance();
|
||||
|
||||
assertThat(pqc.verifyAtHistoric(OWN_HEAD, 0, MESSAGE, sealByIndexZero))
|
||||
.describedAs(
|
||||
"HISTORY door at 1030, armed from 1000, no schedule: a node judging somebody else's "
|
||||
+ "header cannot say which keys were in force there, so it REFUSES. Answering from "
|
||||
+ "the head registry here is D2/T2 verbatim")
|
||||
.isFalse();
|
||||
|
||||
assertThat(pqc.verifyAtOwnHead(OWN_HEAD, 0, MESSAGE, sealByIndexZero))
|
||||
.describedAs(
|
||||
"OWN-HEAD door, SAME height, SAME seal, same instant: this node's own head, where the "
|
||||
+ "head registry IS the answer by construction. Refusing here is what stopped the "
|
||||
+ "proposer and the restart path on 2026-08-06, and it bought no security: the "
|
||||
+ "certificate is re-checked by the other six through the history door")
|
||||
.isTrue();
|
||||
|
||||
assertThat(pqc.addressForIndexAtHistoric(OWN_HEAD, 0))
|
||||
.describedAs("the address halves must split the same way, or R2 and the producer disagree")
|
||||
.isNull();
|
||||
assertThat(pqc.addressForIndexAtOwnHead(OWN_HEAD, 0)).isEqualTo(validators.get(0));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// 2. The restart path, which is five of the six failures. PqSealStore has already forced the
|
||||
// stored block number and hash to equal this node's head before it asks.
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
public void theRESTARTPathAnswersAtAnArmedHeightWithNoSchedule() {
|
||||
final FalconSealSupport pqc = FalconSealSupport.instance();
|
||||
assertThat(pqc.verifyAtOwnHead(OWN_HEAD, 0, MESSAGE, sealByIndexZero))
|
||||
.describedAs(
|
||||
"D141SealPersistenceTest restored 0 of 3 genuine seals under the first shape. A node "
|
||||
+ "that cannot re-read its own seal file after a restart is a node that cannot "
|
||||
+ "propose, and the file is the documented way out of the D-141 deadlock")
|
||||
.isTrue();
|
||||
assertThat(pqc.addressForIndexAtOwnHead(OWN_HEAD, 0))
|
||||
.describedAs("and the index must bind, or every stored seal is dropped as unknown")
|
||||
.isEqualTo(validators.get(0));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// 3. The proposer path, the sixth failure. PqAnchorProducer resolves at the PARENT's height,
|
||||
// and the parent is this node's own head.
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
public void thePROPOSERPathAnswersAtAnArmedHeightWithNoSchedule() {
|
||||
final FalconSealSupport pqc = FalconSealSupport.instance();
|
||||
for (int i = 0; i < 5; i++) {
|
||||
final FalconSigner s = new FalconSigner();
|
||||
s.init(true, privateKeys.get(i));
|
||||
final Bytes sealI = Bytes.wrap(s.generateSignature(MESSAGE.toArray()));
|
||||
assertThat(pqc.verifyAtOwnHead(OWN_HEAD, i, MESSAGE, sealI))
|
||||
.describedAs(
|
||||
"all K=5 genuine seals must resolve, or PqAnchorProducer throws "
|
||||
+ "PqAnchorNotReadyException with '5 were not eligible signers' and the node "
|
||||
+ "stops producing blocks - which is exactly what was measured")
|
||||
.isTrue();
|
||||
assertThat(pqc.addressForIndexAtOwnHead(OWN_HEAD, i)).isEqualTo(validators.get(i));
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// 4. THE OWN-HEAD DOOR IS NOT A LOOPHOLE. This is the assertion that has to hold for the form to
|
||||
// be worth anything: a configured epoch this node does NOT hold fails closed on BOTH doors.
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
public void theOwnHeadDoorIsNOTALoopholeAnUnheldEpochIsRefusedOnBOTHDOORS() throws Exception {
|
||||
final FalconSealSupport pqc = FalconSealSupport.instance();
|
||||
final PqRegistryHash.Registry held = PqRegistryHash.loadAuto(genesisPath);
|
||||
final String hash = PqRegistryHash.hashFor(held, CHAIN_ID);
|
||||
final long rotation = K_AT + 1_000L;
|
||||
|
||||
final Map<Long, String> entries = new LinkedHashMap<>();
|
||||
entries.put(H, hash);
|
||||
entries.put(rotation, "0x" + "cd".repeat(32));
|
||||
pqc.verifyRegistryBindingOrAbort(0L, CHAIN_ID, scheduleFromGenesis(entries));
|
||||
|
||||
assertThat(pqc.verifyAtOwnHead(rotation, 0, MESSAGE, sealByIndexZero))
|
||||
.describedAs(
|
||||
"a rotation the chain HAS scheduled and this node does NOT hold is not a missing "
|
||||
+ "binding, it is a node running a registry the chain has moved off. If the "
|
||||
+ "own-head door answered here it would be a way to sign blocks under a retired "
|
||||
+ "key set, and the split would have bought a liveness fix at the price of the "
|
||||
+ "property the whole anchor exists for")
|
||||
.isFalse();
|
||||
assertThat(pqc.addressForIndexAtOwnHead(rotation, 0)).isNull();
|
||||
|
||||
assertThat(pqc.verifyAtHistoric(rotation, 0, MESSAGE, sealByIndexZero))
|
||||
.describedAs("and the history door refuses identically")
|
||||
.isFalse();
|
||||
|
||||
assertThat(pqc.verifyAtOwnHead(rotation - 1L, 0, MESSAGE, sealByIndexZero))
|
||||
.describedAs("positive control: below the rotation this node holds the epoch and answers")
|
||||
.isTrue();
|
||||
assertThat(pqc.verifyAtHistoric(rotation - 1L, 0, MESSAGE, sealByIndexZero)).isTrue();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// 5. Negative control on the split itself: with the binding CONFIGURED, the two doors converge.
|
||||
// If they do not, the own-head door is not a fallback rule, it is a second key set.
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
public void withTheScheduleConfiguredBOTHDOORSGiveTheSameAnswer() throws Exception {
|
||||
final FalconSealSupport pqc = FalconSealSupport.instance();
|
||||
final PqRegistryHash.Registry held = PqRegistryHash.loadAuto(genesisPath);
|
||||
final Map<Long, String> entries = new LinkedHashMap<>();
|
||||
// AERE D-146 (2026-08-06): hashFor, not hashV1. A schedule entry has to carry the canonical
|
||||
// hash OF THE REGISTRY IT NAMES, and this fixture's registry is now v2, which hashes under a
|
||||
// different domain tag. MEASURED: leaving hashV1 here made the entry name a registry nobody
|
||||
// holds, and the height-resolved lookups fell through to a refusal - a green test turning red
|
||||
// for a reason that had nothing to do with what it measures. This is the same breakage a real
|
||||
// genesis takes: any config.pqRegistryHash computed before the registry was rebuilt as v2
|
||||
// stops matching the moment it is rebuilt.
|
||||
entries.put(H, PqRegistryHash.hashFor(held, CHAIN_ID));
|
||||
pqc.verifyRegistryBindingOrAbort(0L, CHAIN_ID, scheduleFromGenesis(entries));
|
||||
|
||||
for (final long h : new long[] {H - 1L, H, OWN_HEAD, DEEP}) {
|
||||
assertThat(pqc.verifyAtHistoric(h, 0, MESSAGE, sealByIndexZero))
|
||||
.describedAs(
|
||||
"at height "
|
||||
+ h
|
||||
+ " with the epoch bound at the arming height, the history door resolves through "
|
||||
+ "the SCHEDULE, not through any fallback")
|
||||
.isTrue();
|
||||
assertThat(pqc.verifyAtOwnHead(h, 0, MESSAGE, sealByIndexZero))
|
||||
.describedAs(
|
||||
"and the own-head door gives the SAME answer at height "
|
||||
+ h
|
||||
+ ". The two doors differ only in what they do when NOTHING binds the height. If "
|
||||
+ "they differed with a binding in force, the split would have introduced a "
|
||||
+ "second key set rather than a second failure mode")
|
||||
.isTrue();
|
||||
assertThat(pqc.addressForIndexAtHistoric(h, 0)).isEqualTo(pqc.addressForIndexAtOwnHead(h, 0));
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// 6. The live fleet is untouched. aere.pq.anchorBlock is unset on all seven today, so there is
|
||||
// no arming height, and BOTH doors answer exactly as they did before either hardening.
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
public void whenTheAnchorIsNotArmedBOTHDOORSAnswerAndNOTHINGCHANGES() {
|
||||
PqAnchorProducer.useConfigForTesting(PqAnchorConfig.never(CHAIN_ID));
|
||||
final FalconSealSupport pqc = FalconSealSupport.instance();
|
||||
assertThat(pqc.verifyAtHistoric(DEEP, 0, MESSAGE, sealByIndexZero)).isTrue();
|
||||
assertThat(pqc.verifyAtOwnHead(DEEP, 0, MESSAGE, sealByIndexZero)).isTrue();
|
||||
assertThat(pqc.addressForIndexAtHistoric(DEEP, 0)).isEqualTo(validators.get(0));
|
||||
assertThat(pqc.addressForIndexAtOwnHead(DEEP, 0)).isEqualTo(validators.get(0));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// 7. Below the arming height nothing is being judged, so both doors answer. This is the
|
||||
// assertion that goes red first if anybody makes the history door refuse unconditionally.
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
public void belowTheArmingHeightBOTHDOORSAnswer() {
|
||||
final FalconSealSupport pqc = FalconSealSupport.instance();
|
||||
assertThat(pqc.verifyAtHistoric(H - 1L, 0, MESSAGE, sealByIndexZero)).isTrue();
|
||||
assertThat(pqc.verifyAtOwnHead(H - 1L, 0, MESSAGE, sealByIndexZero)).isTrue();
|
||||
assertThat(pqc.verifyAtHistoric(0L, 0, MESSAGE, sealByIndexZero)).isTrue();
|
||||
assertThat(pqc.addressForIndexAtHistoric(H - 1L, 0)).isEqualTo(validators.get(0));
|
||||
assertThat(pqc.addressForIndexAtOwnHead(H - 1L, 0)).isEqualTo(validators.get(0));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// Helpers.
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build a schedule the way a node really gets one: written into a genesis file as {@code
|
||||
* config.pqRegistryHash} and parsed back, so the strictly-increasing rule in the parser is on the
|
||||
* path rather than bypassed.
|
||||
*
|
||||
* @param entries height to 0x-prefixed registry hash, in ascending order of height
|
||||
* @return the parsed schedule
|
||||
* @throws Exception when the temporary genesis cannot be written
|
||||
*/
|
||||
private PqRegistryHash.Schedule scheduleFromGenesis(final Map<Long, String> entries)
|
||||
throws Exception {
|
||||
final List<Long> heights = new ArrayList<>(entries.keySet());
|
||||
heights.sort(Long::compare);
|
||||
final StringBuilder sb = new StringBuilder("{\"config\":{\"pqRegistryHash\":[");
|
||||
for (int i = 0; i < heights.size(); i++) {
|
||||
if (i > 0) {
|
||||
sb.append(',');
|
||||
}
|
||||
final long b = heights.get(i);
|
||||
String h = entries.get(b);
|
||||
if (!h.startsWith("0x")) {
|
||||
h = "0x" + h;
|
||||
}
|
||||
sb.append("{\"block\":").append(b).append(",\"hash\":\"").append(h).append("\"}");
|
||||
}
|
||||
sb.append("]}}");
|
||||
final Path p =
|
||||
tmp.resolve("genesis-schedule-v2-" + heights.size() + "-" + heights.get(0) + ".json");
|
||||
Files.writeString(p, sb.toString());
|
||||
return PqRegistryHash.loadScheduleFromGenesis(p);
|
||||
}
|
||||
|
||||
private static void resetFalconSingleton() throws Exception {
|
||||
final Field f = FalconSealSupport.class.getDeclaredField("instance");
|
||||
f.setAccessible(true);
|
||||
f.set(null, null);
|
||||
}
|
||||
}
|
||||
@ -1,326 +0,0 @@
|
||||
/*
|
||||
* Copyright contributors to Besu / AERE Network.
|
||||
*
|
||||
* 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 org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer;
|
||||
import org.hyperledger.besu.datatypes.Address;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.OptionalInt;
|
||||
|
||||
import org.apache.tuweni.bytes.Bytes;
|
||||
import org.apache.tuweni.bytes.Bytes32;
|
||||
import org.bouncycastle.crypto.digests.KeccakDigest;
|
||||
import org.bouncycastle.pqc.crypto.falcon.FalconPrivateKeyParameters;
|
||||
import org.bouncycastle.pqc.crypto.falcon.FalconSigner;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
/**
|
||||
* D2, the adversarial review of 2026-08-02, at the layer that actually answers the question.
|
||||
*
|
||||
* <p>WHAT THE DOSSIER MEASURED. {@code PqSignerRegistry} had {@code addressForIndex(int)} and {@code
|
||||
* verify(int, Bytes, Bytes)} with no height, and {@code FalconSealSupport} held ONE registry loaded
|
||||
* at start-up. So a header that passed both anchor rules was REJECTED the moment index 0's Falcon
|
||||
* key was rotated - same header, same parent, same validator set.
|
||||
*
|
||||
* <p>WHAT WAS REPAIRED BEFORE THIS FILE, AND WHAT WAS NOT. Commit f3ebe90c (D-081) gave the
|
||||
* validation path {@code addressForIndexAt} / {@code verifyAt} and a height-indexed schedule. The
|
||||
* measurement of 2026-08-05 found the repair INERT, for a reason that is one line long: with no
|
||||
* {@code config.pqRegistryHash} in genesis - and there is none in any genesis this fleet runs -
|
||||
* {@code keyAt} fell back to the registry in force AT THE HEAD, at every height. Height-aware
|
||||
* signatures, head-registry answers. T2 stood exactly as measured.
|
||||
*
|
||||
* <p>WHAT THIS FILE ASSERTS, as a property and not as a scenario: <b>at and above the arming height,
|
||||
* a node that cannot say which key set was in force must REFUSE, not guess.</b> Below the arming
|
||||
* height it must keep answering from the head registry, because nothing there is being judged and
|
||||
* the 11.8 million blocks already on chain 2800 must behave bit for bit as they did.
|
||||
*
|
||||
* <p>THE NEGATIVE CONTROL IS BUILT IN, not promised. {@link
|
||||
* #belowTheArmingHeightTheHeadRegistryStillAnswers()} fails if the refusal is made unconditional;
|
||||
* {@link #whenTheAnchorIsNotArmedNOTHINGCHANGES()} fails if it is made independent of arming; {@link
|
||||
* #withTheScheduleConfiguredTheArmedHeightsAnswerAgain()} fails if the refusal is anything other
|
||||
* than a missing height-to-registry binding. And the measurement itself, {@link
|
||||
* #d2t2AtAndAboveTheArmingHeightWithNoScheduleTheAnswerIsRefusal()}, is GREEN on the unrepaired code
|
||||
* only if the fallback is restored - which is exactly the one-line edit the repair removed.
|
||||
*/
|
||||
public class D2RegistryHeightRefusalTest {
|
||||
|
||||
/** Anchor activation height H: from here a header's Falcon certificate carries weight. */
|
||||
private static final long H = 1_000L;
|
||||
|
||||
/** Height from which the staged threshold is non-zero, i.e. the fully armed regime. */
|
||||
private static final long K_AT = H + 10L;
|
||||
|
||||
/** A height far above H, standing in for "a year of history above the arming height". */
|
||||
private static final long DEEP = K_AT + 5_000L;
|
||||
|
||||
private static final int N = 7;
|
||||
|
||||
private static final long CHAIN_ID = 220_878L;
|
||||
|
||||
@TempDir private Path tmp;
|
||||
|
||||
private final List<FalconPrivateKeyParameters> privateKeys = new ArrayList<>();
|
||||
private final List<Address> validators = new ArrayList<>();
|
||||
private Path genesisPath;
|
||||
|
||||
/** A fixed 32-byte message, standing in for M(parent) or a committed-seal hash. */
|
||||
private static final Bytes32 MESSAGE =
|
||||
Bytes32.fromHexString("0x" + "5a".repeat(32));
|
||||
|
||||
private Bytes sealByIndexZero;
|
||||
|
||||
@BeforeEach
|
||||
public void setUp() throws Exception {
|
||||
// AERE D-146 (2026-08-06): v2, proof-bound, bound at H. See PqV2Fixture for why the addresses
|
||||
// are derived from real secp256k1 keys and can no longer be spelled 0xA00+i.
|
||||
final KeccakDigest kd = new KeccakDigest(256);
|
||||
final StringBuilder manifest = new StringBuilder();
|
||||
manifest
|
||||
.append("{\"config\":{\"aereFalconRegistry\":{")
|
||||
.append(PqV2Fixture.manifestHeader(N, CHAIN_ID, H));
|
||||
for (int i = 0; i < N; i++) {
|
||||
privateKeys.add(PqV2Fixture.privateKey(i));
|
||||
validators.add(PqV2Fixture.address(i));
|
||||
final byte[] anchoredRow = PqV2Fixture.anchorPreimageRow(i);
|
||||
kd.update(anchoredRow, 0, anchoredRow.length);
|
||||
manifest.append(',').append(PqV2Fixture.manifestEntry(i, N, CHAIN_ID, H));
|
||||
}
|
||||
manifest
|
||||
.append("}},\"alloc\":{\"0000000000000000000000000000000000000fa1\":{\"storage\":{\"0x")
|
||||
.append("0".repeat(64))
|
||||
.append("\":\"0x");
|
||||
final byte[] anchoredHash = new byte[32];
|
||||
kd.doFinal(anchoredHash, 0);
|
||||
manifest.append(Bytes.wrap(anchoredHash).toUnprefixedHexString()).append("\"}}}}");
|
||||
|
||||
genesisPath = tmp.resolve("genesis-d2.json");
|
||||
Files.writeString(genesisPath, manifest.toString());
|
||||
System.setProperty("aere.falcon.genesis", genesisPath.toAbsolutePath().toString());
|
||||
|
||||
// A genuine Falcon-512 signature by index 0 over MESSAGE. Everything below asks one question of
|
||||
// it: at which heights does the node agree that this is index 0's signature.
|
||||
final FalconSigner signer = new FalconSigner();
|
||||
signer.init(true, privateKeys.get(0));
|
||||
sealByIndexZero = Bytes.wrap(signer.generateSignature(MESSAGE.toArray()));
|
||||
|
||||
resetFalconSingleton();
|
||||
// ARMED at H. On the live fleet aere.pq.anchorBlock is unset and this whole file's subject
|
||||
// does not exist; see whenTheAnchorIsNotArmedNOTHINGCHANGES.
|
||||
PqAnchorProducer.useConfigForTesting(
|
||||
new PqAnchorConfig(CHAIN_ID, H, Map.of(H, 0, K_AT, 3), OptionalInt.empty(), false));
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
public void tearDown() throws Exception {
|
||||
System.clearProperty("aere.falcon.genesis");
|
||||
System.clearProperty("aere.pq.genesis");
|
||||
System.clearProperty(FalconSealSupport.PROPERTY_REGISTRY_HISTORY);
|
||||
resetFalconSingleton();
|
||||
PqAnchorProducer.useConfigForTesting(null);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// 0. The fixture itself. Without this, a green run below could mean the registry never loaded.
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
public void baselineTheFixtureIsGenesisAnchoredAndTheSealIsGENUINE() {
|
||||
final FalconSealSupport pqc = FalconSealSupport.instance();
|
||||
assertThat(pqc.genesisAnchored())
|
||||
.describedAs("the fixture must load a GENESIS-ANCHORED registry, or nothing here means anything")
|
||||
.isTrue();
|
||||
assertThat(pqc.addressBound()).isTrue();
|
||||
assertThat(pqc.verify(0, MESSAGE, sealByIndexZero))
|
||||
.describedAs("the seal must be a REAL Falcon signature under the head registry")
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// 1. THE MEASUREMENT. Chain 2800 as it stands: armed, and no pqRegistryHash anywhere.
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
public void d2t2AtAndAboveTheArmingHeightWithNoScheduleTheAnswerIsRefusal() {
|
||||
final FalconSealSupport pqc = FalconSealSupport.instance();
|
||||
|
||||
// No schedule was ever loaded: verifyRegistryBindingOrAbort has not run, which is the state of
|
||||
// every node on chain 2800 today, because config.pqRegistryHash is in no genesis this fleet
|
||||
// runs (measured 2026-08-05, grep over deploy/ and monitoring/ returns nothing).
|
||||
assertThat(pqc.verifyAtHistoric(H, 0, MESSAGE, sealByIndexZero))
|
||||
.describedAs(
|
||||
"D2/T2: at the arming height itself, a node with no height-to-registry binding must "
|
||||
+ "REFUSE. Before 2026-08-06 it answered from the registry in force at the HEAD, "
|
||||
+ "so one key rotation made every block above H unverifiable while the node "
|
||||
+ "reported success")
|
||||
.isFalse();
|
||||
|
||||
assertThat(pqc.verifyAtHistoric(DEEP, 0, MESSAGE, sealByIndexZero))
|
||||
.describedAs("and the same, far above the arming height")
|
||||
.isFalse();
|
||||
|
||||
assertThat(pqc.addressForIndexAtHistoric(DEEP, 0))
|
||||
.describedAs(
|
||||
"the address half must refuse identically: PqAnchorSealsRule refuses an index it "
|
||||
+ "cannot bind, and a bound-by-guess address is worse than an unbound one")
|
||||
.isNull();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// 2. Negative control: the refusal is HEIGHT-GATED. A rule that always refuses is not a repair.
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
public void belowTheArmingHeightTheHeadRegistryStillAnswers() {
|
||||
final FalconSealSupport pqc = FalconSealSupport.instance();
|
||||
assertThat(pqc.verifyAtHistoric(H - 1L, 0, MESSAGE, sealByIndexZero))
|
||||
.describedAs(
|
||||
"one block below H nothing is being judged, so the head registry is the right answer "
|
||||
+ "and the 11.8 million blocks already on chain must behave exactly as before")
|
||||
.isTrue();
|
||||
assertThat(pqc.verifyAtHistoric(0L, 0, MESSAGE, sealByIndexZero)).isTrue();
|
||||
assertThat(pqc.addressForIndexAtHistoric(H - 1L, 0)).isEqualTo(validators.get(0));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// 3. Negative control: the refusal is ARMING-gated. This is the proof that the live fleet is
|
||||
// untouched, and it is the assertion that fails first if that stops being true.
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
public void whenTheAnchorIsNotArmedNOTHINGCHANGES() {
|
||||
PqAnchorProducer.useConfigForTesting(PqAnchorConfig.never(CHAIN_ID));
|
||||
final FalconSealSupport pqc = FalconSealSupport.instance();
|
||||
assertThat(pqc.verifyAtHistoric(DEEP, 0, MESSAGE, sealByIndexZero))
|
||||
.describedAs(
|
||||
"chain 2800 today: aere.pq.anchorBlock unset, so there is no arming height, no height "
|
||||
+ "is at or above it, and every answer is what it was before this repair")
|
||||
.isTrue();
|
||||
assertThat(pqc.addressForIndexAtHistoric(DEEP, 0)).isEqualTo(validators.get(0));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// 4. Positive control: what the refusal is a refusal ABOUT. Configure the binding and the armed
|
||||
// heights answer again - through the height-resolved path, not the head-registry fallback.
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
public void withTheScheduleConfiguredTheArmedHeightsAnswerAgain() throws Exception {
|
||||
final FalconSealSupport pqc = FalconSealSupport.instance();
|
||||
final PqRegistryHash.Registry held = PqRegistryHash.loadAuto(genesisPath);
|
||||
final String hash = PqRegistryHash.hashFor(held, CHAIN_ID);
|
||||
|
||||
// The first scheduled entry sits EXACTLY at the arming height, which is the rule the epoch-list
|
||||
// design states: below H requiredHashAt is empty and the fallback is unreachable by anything
|
||||
// that decides a header.
|
||||
// AERE D-146 (2026-08-06): the hash above is hashFor, not hashV1, because this fixture's
|
||||
// registry is now v2 and hashes under a different domain tag. A schedule entry that names the
|
||||
// v1 number names a registry this node does not hold.
|
||||
final PqRegistryHash.Schedule schedule = scheduleFromGenesis(Map.of(H, hash));
|
||||
pqc.verifyRegistryBindingOrAbort(0L, CHAIN_ID, schedule);
|
||||
|
||||
assertThat(pqc.verifyAtHistoric(DEEP, 0, MESSAGE, sealByIndexZero))
|
||||
.describedAs(
|
||||
"with the epoch bound at H and the registry held, the armed heights resolve through "
|
||||
+ "the schedule. If this is false the refusal is not about a missing binding and "
|
||||
+ "the measurement above proves nothing")
|
||||
.isTrue();
|
||||
assertThat(pqc.addressForIndexAtHistoric(DEEP, 0)).isEqualTo(validators.get(0));
|
||||
assertThat(pqc.verifyAtHistoric(H - 1L, 0, MESSAGE, sealByIndexZero))
|
||||
.describedAs("and below H the fallback is still the answer")
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// 5. The case the epoch list exists FOR: an epoch this node does not hold. Refused, and named.
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
public void anEpochThisNodeDoesNotHoldIsRefusedAndNAMED() throws Exception {
|
||||
final FalconSealSupport pqc = FalconSealSupport.instance();
|
||||
final PqRegistryHash.Registry held = PqRegistryHash.loadAuto(genesisPath);
|
||||
final String hash = PqRegistryHash.hashFor(held, CHAIN_ID);
|
||||
final long rotation = K_AT + 1_000L;
|
||||
|
||||
// Two epochs: the one this node holds, and a rotation to a registry it was never given. This is
|
||||
// the shape of "an operator rotated a compromised key and one node did not get the file".
|
||||
final PqRegistryHash.Schedule schedule =
|
||||
scheduleFromGenesis(
|
||||
new java.util.LinkedHashMap<>(
|
||||
Map.of(H, hash, rotation, "0x" + "cd".repeat(32))));
|
||||
pqc.verifyRegistryBindingOrAbort(0L, CHAIN_ID, schedule);
|
||||
|
||||
assertThat(pqc.verifyAtHistoric(rotation - 1L, 0, MESSAGE, sealByIndexZero))
|
||||
.describedAs("below the rotation this node holds the epoch and answers")
|
||||
.isTrue();
|
||||
assertThat(pqc.verifyAtHistoric(rotation, 0, MESSAGE, sealByIndexZero))
|
||||
.describedAs(
|
||||
"at the rotation the epoch is covered by NOTHING this node holds. It stops here; it "
|
||||
+ "does not answer from whatever it happens to have")
|
||||
.isFalse();
|
||||
assertThat(pqc.addressForIndexAtHistoric(rotation, 0)).isNull();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// Helpers.
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build a schedule the way a node really gets one: written into a genesis file as {@code
|
||||
* config.pqRegistryHash} and parsed back. Constructing the object directly would skip the parser,
|
||||
* which is where the strictly-increasing rule lives.
|
||||
*
|
||||
* @param entries height to 0x-prefixed registry hash, in ascending order of height
|
||||
* @return the parsed schedule
|
||||
* @throws Exception when the temporary genesis cannot be written
|
||||
*/
|
||||
private PqRegistryHash.Schedule scheduleFromGenesis(final Map<Long, String> entries)
|
||||
throws Exception {
|
||||
final List<Long> heights = new ArrayList<>(entries.keySet());
|
||||
heights.sort(Long::compare);
|
||||
final StringBuilder sb = new StringBuilder("{\"config\":{\"pqRegistryHash\":[");
|
||||
for (int i = 0; i < heights.size(); i++) {
|
||||
if (i > 0) {
|
||||
sb.append(',');
|
||||
}
|
||||
final long b = heights.get(i);
|
||||
String h = entries.get(b);
|
||||
if (!h.startsWith("0x")) {
|
||||
h = "0x" + h;
|
||||
}
|
||||
sb.append("{\"block\":").append(b).append(",\"hash\":\"").append(h).append("\"}");
|
||||
}
|
||||
sb.append("]}}");
|
||||
final Path p = tmp.resolve("genesis-schedule-" + heights.size() + "-" + heights.get(0) + ".json");
|
||||
Files.writeString(p, sb.toString());
|
||||
return PqRegistryHash.loadScheduleFromGenesis(p);
|
||||
}
|
||||
|
||||
private static void resetFalconSingleton() throws Exception {
|
||||
final Field f = FalconSealSupport.class.getDeclaredField("instance");
|
||||
f.setAccessible(true);
|
||||
f.set(null, null);
|
||||
}
|
||||
}
|
||||
@ -3,8 +3,10 @@
|
||||
*
|
||||
* WHY THIS EXISTS, and the cost that demanded it. On 8 August Falcon seal attachment was switched
|
||||
* on across all seven validators of chain 2800. The header went from 525 to 3844 bytes, that is
|
||||
* FIVE seals on EVERY block. At ~60.3 million blocks per year that is ~200 GB per year per node,
|
||||
* measured, and the tightest host had 12 GB free. That is 23 days.
|
||||
* FIVE seals on EVERY block: more than seven times the previous header, every ~523 ms. On a chain
|
||||
* with no transactions the headers are close to everything that gets written to disk, so that
|
||||
* multiplier is exactly the multiplier of database growth, and it exceeds any reasonable
|
||||
* provisioning.
|
||||
*
|
||||
* AND NOW THE PART THAT IS THE ACTUAL FINDING. The anchor producer already had both an interval and
|
||||
* a cap, built and proven on 7 August. But the assembler that runs when the anchor is NOT armed had
|
||||
@ -25,7 +27,7 @@
|
||||
* and it is a dial, not an accident. Algorand ships the same shape at 1 in 256 or rarer.
|
||||
*
|
||||
* Same property as in `PqAnchorIntervalTest`, a different code path. Two paths need two proofs:
|
||||
* precisely because we had a proof on one of them only, we paid 200 GB per year on the other.
|
||||
* precisely because we had a proof on one of them only, we paid the full cost on the other.
|
||||
*/
|
||||
package org.hyperledger.besu.consensus.common.bft;
|
||||
|
||||
@ -93,23 +95,23 @@ class FalconAttachIntervalTest {
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
@Test
|
||||
void anIntervalOfOneHundredCarriesExactlyOneHeightInOneHundred() {
|
||||
int with = 0;
|
||||
int cu = 0;
|
||||
for (long n = ATTACH; n < ATTACH + 10_000; n++) {
|
||||
if (FalconSealSupport.isAttachHeight(n, ATTACH, OptionalInt.of(100))) {
|
||||
with++;
|
||||
cu++;
|
||||
assertThat((n - ATTACH) % 100).as("height %d is not a multiple", n).isZero();
|
||||
}
|
||||
}
|
||||
assertThat(with).isEqualTo(100);
|
||||
assertThat(cu).isEqualTo(100);
|
||||
|
||||
// and the same span of heights WITH NO interval, so the difference we are buying is visible
|
||||
int without = 0;
|
||||
int fara = 0;
|
||||
for (long n = ATTACH; n < ATTACH + 10_000; n++) {
|
||||
if (FalconSealSupport.isAttachHeight(n, ATTACH, OptionalInt.empty())) {
|
||||
without++;
|
||||
fara++;
|
||||
}
|
||||
}
|
||||
assertThat(without).isEqualTo(10_000);
|
||||
assertThat(fara).isEqualTo(10_000);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
@ -205,21 +207,23 @@ class FalconAttachIntervalTest {
|
||||
@Test
|
||||
void theMeasuredCostOfEachSettingIsWhatWeToldTheFounder() {
|
||||
final long blocuriPeAn = 60_300_000L;
|
||||
final long bytesPerSeal = 662L;
|
||||
final long octetiPeSigiliu = 662L;
|
||||
|
||||
assertThat(gbPeAn(5, 1, bytesPerSeal, blocuriPeAn)).isBetween(180L, 210L); // today
|
||||
assertThat(gbPeAn(3, 1, bytesPerSeal, blocuriPeAn)).isBetween(105L, 125L); // cap only
|
||||
assertThat(gbPeAn(3, 32, bytesPerSeal, blocuriPeAn)).isBetween(3L, 5L); // cap + 32
|
||||
assertThat(gbPeAn(3, 100, bytesPerSeal, blocuriPeAn)).isBetween(1L, 2L); // cap + 100
|
||||
assertThat(gbPeAn(5, 1, octetiPeSigiliu, blocuriPeAn)).isBetween(180L, 210L); // today
|
||||
assertThat(gbPeAn(3, 1, octetiPeSigiliu, blocuriPeAn)).isBetween(105L, 125L); // cap only
|
||||
assertThat(gbPeAn(3, 32, octetiPeSigiliu, blocuriPeAn)).isBetween(3L, 5L); // cap + 32
|
||||
assertThat(gbPeAn(3, 100, octetiPeSigiliu, blocuriPeAn)).isBetween(1L, 2L); // cap + 100
|
||||
|
||||
// and the boundary that matters for the disk decision: at 12 GB free, how many days are left
|
||||
assertThat(zile(12L, gbPeAn(5, 1, bytesPerSeal, blocuriPeAn))).isLessThan(30L);
|
||||
assertThat(zile(12L, gbPeAn(3, 32, bytesPerSeal, blocuriPeAn))).isGreaterThan(700L);
|
||||
// and the boundary that matters for any provisioning decision: starting from a fixed space
|
||||
// budget, how many days each setting lasts. The budget below is a parameter of the proof, kept
|
||||
// deliberately small so that the order of magnitude between the settings is visible.
|
||||
assertThat(zile(12L, gbPeAn(5, 1, octetiPeSigiliu, blocuriPeAn))).isLessThan(30L);
|
||||
assertThat(zile(12L, gbPeAn(3, 32, octetiPeSigiliu, blocuriPeAn))).isGreaterThan(700L);
|
||||
}
|
||||
|
||||
private static long gbPeAn(
|
||||
final int seals, final int interval, final long bytesPerSeal, final long blocuriPeAn) {
|
||||
return (long) seals * bytesPerSeal * blocuriPeAn / interval / (1024L * 1024L * 1024L);
|
||||
final int sigilii, final int interval, final long octetiPeSigiliu, final long blocuriPeAn) {
|
||||
return (long) sigilii * octetiPeSigiliu * blocuriPeAn / interval / (1024L * 1024L * 1024L);
|
||||
}
|
||||
|
||||
private static long zile(final long gbLiberi, final long gbPeAn) {
|
||||
|
||||
@ -1,177 +0,0 @@
|
||||
/* AERE HYBRID: the PRODUCER's proofs, and its pairing with enforcement.
|
||||
*
|
||||
* The proof that ties the two halves is the last one: what the producer PRODUCES must pass
|
||||
* exactly the verification the consumer performs, with real test keys and the same message.
|
||||
* Two halves proven separately that were never put end to end are the very pattern that cost
|
||||
* us the most (D-150: every shape-level check had passed). */
|
||||
package org.hyperledger.besu.consensus.common.bft;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import org.hyperledger.besu.crypto.SecureRandomProvider;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.apache.tuweni.bytes.Bytes;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class HybridSealProducerTest {
|
||||
|
||||
private static final long H_ATASARE = 500L;
|
||||
private static final long H_HIBRID = 1_000L;
|
||||
private static final int INDEX = 4;
|
||||
private static final Bytes MESSAGE = Bytes.fromHexString("0x" + "5a".repeat(32));
|
||||
|
||||
private SealScheme.GeneratedPair slh;
|
||||
private PqSchemeSchedule orar;
|
||||
|
||||
private final SecureRandom random = SecureRandomProvider.createSecureRandom();
|
||||
|
||||
@BeforeEach
|
||||
void setup() {
|
||||
slh = SealSchemes.SLH_DSA_128S.generate(random);
|
||||
orar =
|
||||
PqSchemeSchedule.parse(
|
||||
"0:" + SealSchemes.FALCON_512.id()
|
||||
+ "," + H_HIBRID + ":" + SealSchemes.FALCON_512.id()
|
||||
+ "+" + SealSchemes.SLH_DSA_128S.id());
|
||||
}
|
||||
|
||||
private HybridSealProducer producator() {
|
||||
return new HybridSealProducer(
|
||||
H_ATASARE, orar, INDEX, Map.of(SealSchemes.SLH_DSA_128S.id(), slh.privateKey()));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- poarta de emisie
|
||||
|
||||
@Test
|
||||
void theDefaultProducerNeverEmitsAnything() {
|
||||
assertThat(HybridSealProducer.disarmed().sealsFor(Long.MAX_VALUE - 1, MESSAGE)).isEmpty();
|
||||
assertThat(HybridSealProducer.disarmed().attachmentArmedAt(Long.MAX_VALUE - 1)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void belowTheAttachmentHeightNothingIsEmitted() {
|
||||
assertThat(producator().sealsFor(H_ATASARE - 1, MESSAGE)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void betweenAttachmentAndTheHybridStepThereIsNothingToAdd() {
|
||||
// the gate is open, but the schedule requires only Falcon, which has its own slot: zero extras, correctly
|
||||
assertThat(producator().attachmentArmedAt(H_ATASARE)).isTrue();
|
||||
assertThat(producator().sealsFor(H_ATASARE, MESSAGE)).isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* THE PROOF THAT ACTUALLY SEPARATES THE TWO CASES. The first form of the boundary proof
|
||||
* above passed for the wrong reason: below the attach height the schedule required no extra
|
||||
* scheme anyway, so an empty list said nothing about the gate. Here the schedule REQUIRES,
|
||||
* and the only remaining difference is the gate. Without this, a producer with its gate
|
||||
* removed would have stayed green.
|
||||
*/
|
||||
@Test
|
||||
void theGateAloneSuppressesEmissionEvenWhenTheScheduleDemandsIt() {
|
||||
final HybridSealProducer poartaInchisa =
|
||||
new HybridSealProducer(
|
||||
H_HIBRID + 100,
|
||||
orar,
|
||||
INDEX,
|
||||
Map.of(SealSchemes.SLH_DSA_128S.id(), slh.privateKey()));
|
||||
assertThat(poartaInchisa.sealsFor(H_HIBRID, MESSAGE)).isEmpty();
|
||||
assertThat(poartaInchisa.sealsFor(H_HIBRID + 100, MESSAGE)).hasSize(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void atTheHybridStepTheExtraSealIsProduced() {
|
||||
final List<SchemeSeal> seals = producator().sealsFor(H_HIBRID, MESSAGE);
|
||||
assertThat(seals).hasSize(1);
|
||||
assertThat(seals.get(0).getSchemeWireId()).isEqualTo(SealSchemes.SLH_DSA_128S.wireId());
|
||||
assertThat(seals.get(0).getValidatorIndex()).isEqualTo(INDEX);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- jumatatea de certificat
|
||||
|
||||
@Test
|
||||
void aMissingKeyEmitsNothingAtAllRatherThanAStubCertificate() {
|
||||
final HybridSealProducer withoutKey =
|
||||
new HybridSealProducer(H_ATASARE, orar, INDEX, Map.of());
|
||||
assertThat(withoutKey.sealsFor(H_HIBRID, MESSAGE)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void aNullMessageIsRefusedWithoutThrowing() {
|
||||
assertThat(producator().sealsFor(H_HIBRID, null)).isEmpty();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- dus-intorsul cheii
|
||||
|
||||
@Test
|
||||
void theSlhDsaPrivateKeySurvivesSerializationAndStillSigns() {
|
||||
final byte[] encoded =
|
||||
SealSchemes.SLH_DSA_128S.serializePrivateKey(slh.privateKey()).orElseThrow();
|
||||
final SealScheme.PrivateHandle back =
|
||||
SealSchemes.SLH_DSA_128S.parsePrivateKey(encoded).orElseThrow();
|
||||
|
||||
final byte[] semnat =
|
||||
SealSchemes.SLH_DSA_128S.sign(back, MESSAGE.toArray()).orElseThrow();
|
||||
assertThat(
|
||||
SealSchemes.SLH_DSA_128S.verifyRaw(
|
||||
slh.publicRegistryForm(), MESSAGE.toArray(), semnat))
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void garbageIsNotAPrivateKeyAndFalconDeliberatelyHasNoEncoding() {
|
||||
assertThat(SealSchemes.SLH_DSA_128S.parsePrivateKey(new byte[] {1, 2, 3})).isEmpty();
|
||||
assertThat(SealSchemes.SLH_DSA_128S.parsePrivateKey(null)).isEmpty();
|
||||
// Falcon NU implementeaza dus-intorsul: incarcarea lui de productie ramane pe componente,
|
||||
// neatinsa. Daca cineva o implementeaza intr-o zi, proba asta il obliga sa se uite aici.
|
||||
final SealScheme.GeneratedPair falcon = SealSchemes.FALCON_512.generate(random);
|
||||
assertThat(SealSchemes.FALCON_512.serializePrivateKey(falcon.privateKey())).isEmpty();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CELE DOUA JUMATATI, LEGATE
|
||||
|
||||
@Test
|
||||
void whatTheProducerEmitsIsExactlyWhatTheRegistryVerifies() {
|
||||
// producatorul semneaza...
|
||||
final List<SchemeSeal> produse = producator().sealsFor(H_HIBRID, MESSAGE);
|
||||
assertThat(produse).hasSize(1);
|
||||
|
||||
// ...and a REAL hybrid registry, built from properties as in production, verifies it
|
||||
// the registry REFUSES a missing entry (its guard, first caught by this very proof),
|
||||
// so it is built whole: every validator up to our index
|
||||
final java.util.Properties p = new java.util.Properties();
|
||||
p.setProperty("formatVersion", HybridSignerRegistry.FORMAT_VERSION);
|
||||
p.setProperty("chainId", "2800");
|
||||
p.setProperty("count", String.valueOf(INDEX + 1));
|
||||
for (int i = 0; i <= INDEX; i++) {
|
||||
p.setProperty(i + ".addr", "0x" + String.format("%02x", 0xc0 + i).repeat(20));
|
||||
final byte[] pub =
|
||||
i == INDEX
|
||||
? slh.publicRegistryForm()
|
||||
: SealSchemes.SLH_DSA_128S.generate(random).publicRegistryForm();
|
||||
p.setProperty(
|
||||
i + ".key." + SealSchemes.SLH_DSA_128S.id(), Bytes.wrap(pub).toHexString());
|
||||
}
|
||||
final HybridSignerRegistry registry = HybridSignerRegistry.fromProperties(p, "proba");
|
||||
|
||||
final byte[] cheiePublica =
|
||||
registry.publicKey(INDEX, SealSchemes.SLH_DSA_128S.id()).orElseThrow();
|
||||
assertThat(
|
||||
SealSchemes.SLH_DSA_128S.verifyRaw(
|
||||
cheiePublica, MESSAGE.toArray(), produse.get(0).getSignature().toArray()))
|
||||
.isTrue();
|
||||
|
||||
// the binding's NEGATIVE CONTROL: the same seal over a DIFFERENT message does not pass
|
||||
assertThat(
|
||||
SealSchemes.SLH_DSA_128S.verifyRaw(
|
||||
cheiePublica,
|
||||
Bytes.fromHexString("0x" + "5b".repeat(32)).toArray(),
|
||||
produse.get(0).getSignature().toArray()))
|
||||
.isFalse();
|
||||
}
|
||||
}
|
||||
@ -1,206 +0,0 @@
|
||||
/* AERE HYBRID: the PRODUCTION loader's proofs. Each configuration half refuses with its own
|
||||
* code; the happy path reaches a producer that really signs, with REAL test keys, and its
|
||||
* signature is verified against the public key from the registry loaded off "disk"
|
||||
* (a fake ConfigReader: no real file, no global property, zero JVM poisoning). */
|
||||
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.hyperledger.besu.crypto.SecureRandomProvider;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.apache.tuweni.bytes.Bytes;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class HybridSealSupportTest {
|
||||
|
||||
private static final long H_HIBRID = 900L;
|
||||
private static final int INDEX = 2;
|
||||
|
||||
private final SecureRandom random = SecureRandomProvider.createSecureRandom();
|
||||
private SealScheme.GeneratedPair slh;
|
||||
private String orar;
|
||||
private String registruText;
|
||||
|
||||
/** Cititor fals: proprietati si "fisiere" din memorie. */
|
||||
private static final class Cititor implements HybridSealSupport.ConfigReader {
|
||||
final Map<String, String> props = new HashMap<>();
|
||||
final Map<String, byte[]> files = new HashMap<>();
|
||||
|
||||
@Override
|
||||
public String property(final String name) {
|
||||
return props.get(name);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String environment(final String name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] file(final String path) throws IOException {
|
||||
final byte[] b = files.get(path);
|
||||
if (b == null) {
|
||||
throw new IOException("nu exista: " + path);
|
||||
}
|
||||
return b;
|
||||
}
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setup() {
|
||||
slh = SealSchemes.SLH_DSA_128S.generate(random);
|
||||
orar =
|
||||
"0:" + SealSchemes.FALCON_512.id()
|
||||
+ "," + H_HIBRID + ":" + SealSchemes.FALCON_512.id()
|
||||
+ "+" + SealSchemes.SLH_DSA_128S.id();
|
||||
final StringBuilder r = new StringBuilder();
|
||||
r.append("formatVersion=").append(HybridSignerRegistry.FORMAT_VERSION).append('\n');
|
||||
r.append("chainId=2800\n");
|
||||
r.append("count=").append(INDEX + 1).append('\n');
|
||||
for (int i = 0; i <= INDEX; i++) {
|
||||
final byte[] pub =
|
||||
i == INDEX
|
||||
? slh.publicRegistryForm()
|
||||
: SealSchemes.SLH_DSA_128S.generate(random).publicRegistryForm();
|
||||
r.append(i).append(".addr=0x").append(String.format("%02x", 0xd0 + i).repeat(20)).append('\n');
|
||||
r.append(i).append(".key.").append(SealSchemes.SLH_DSA_128S.id()).append('=')
|
||||
.append(Bytes.wrap(pub).toHexString()).append('\n');
|
||||
}
|
||||
registruText = r.toString();
|
||||
}
|
||||
|
||||
private Cititor cuPereche() {
|
||||
final Cititor c = new Cititor();
|
||||
c.props.put(HybridSealSupport.PROPERTY_SCHEDULE, orar);
|
||||
c.props.put(HybridSealSupport.PROPERTY_REGISTRY, "/fals/registru.properties");
|
||||
c.files.put("/fals/registru.properties", registruText.getBytes(StandardCharsets.UTF_8));
|
||||
return c;
|
||||
}
|
||||
|
||||
private void withKey(final Cititor c, final int index, final byte[] sk) {
|
||||
c.props.put(
|
||||
HybridSealSupport.PROPERTY_KEY_PREFIX + SealSchemes.SLH_DSA_128S.id(),
|
||||
"/fals/cheia.properties");
|
||||
c.files.put(
|
||||
"/fals/cheia.properties",
|
||||
("index=" + index + "\nsk=" + Bytes.wrap(sk).toHexString() + "\n")
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private byte[] skBytes() {
|
||||
return SealSchemes.SLH_DSA_128S.serializePrivateKey(slh.privateKey()).orElseThrow();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ dezarmat si refuzuri
|
||||
|
||||
@Test
|
||||
void nothingConfiguredMeansTodayByteForByte() {
|
||||
final HybridSealSupport s = HybridSealSupport.load(new Cititor());
|
||||
assertThat(s.schedule()).isEmpty();
|
||||
assertThat(s.registry()).isEmpty();
|
||||
assertThat(s.producer().sealsFor(Long.MAX_VALUE - 1, Bytes.of(1))).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void scheduleWithoutRegistryRefusesAsConf03() {
|
||||
final Cititor c = new Cititor();
|
||||
c.props.put(HybridSealSupport.PROPERTY_SCHEDULE, orar);
|
||||
assertThatThrownBy(() -> HybridSealSupport.load(c))
|
||||
.hasMessageContaining("AERE-PQC-COMMIT-CONF-03");
|
||||
}
|
||||
|
||||
@Test
|
||||
void attachWithoutThePairRefusesAsConf04() {
|
||||
final Cititor c = new Cititor();
|
||||
c.props.put(HybridSealSupport.PROPERTY_ATTACH_BLOCK, "100");
|
||||
assertThatThrownBy(() -> HybridSealSupport.load(c))
|
||||
.hasMessageContaining("AERE-PQC-HYBRID-CONF-04");
|
||||
}
|
||||
|
||||
@Test
|
||||
void garbageScheduleRefusesLoudly() {
|
||||
final Cititor c = cuPereche();
|
||||
c.props.put(HybridSealSupport.PROPERTY_SCHEDULE, "aiurea:schema-inexistenta");
|
||||
assertThatThrownBy(() -> HybridSealSupport.load(c))
|
||||
.hasMessageContaining("AERE-PQC-HYBRID-CONF-04");
|
||||
}
|
||||
|
||||
@Test
|
||||
void unreadableRegistryRefusesLoudly() {
|
||||
final Cititor c = cuPereche();
|
||||
c.files.clear();
|
||||
assertThatThrownBy(() -> HybridSealSupport.load(c))
|
||||
.hasMessageContaining("AERE-PQC-HYBRID-CONF-04");
|
||||
}
|
||||
|
||||
@Test
|
||||
void armedEmissionWithoutALocalKeyRefusesAsConf04() {
|
||||
final Cititor c = cuPereche();
|
||||
c.props.put(HybridSealSupport.PROPERTY_ATTACH_BLOCK, "100");
|
||||
assertThatThrownBy(() -> HybridSealSupport.load(c))
|
||||
.hasMessageContaining("AERE-PQC-HYBRID-CONF-04")
|
||||
.hasMessageContaining("cannot produce");
|
||||
}
|
||||
|
||||
@Test
|
||||
void aKeyTheRegistryDoesNotVouchForRefusesAsConf05() {
|
||||
final Cititor c = cuPereche();
|
||||
// my real key, but declared at index 0, where the registry holds a DIFFERENT public key
|
||||
withKey(c, 0, skBytes());
|
||||
assertThatThrownBy(() -> HybridSealSupport.load(c))
|
||||
.hasMessageContaining("AERE-PQC-HYBRID-CONF-05")
|
||||
.hasMessageContaining("does NOT verify");
|
||||
}
|
||||
|
||||
@Test
|
||||
void garbageKeyBytesRefuseAsConf05() {
|
||||
final Cititor c = cuPereche();
|
||||
withKey(c, INDEX, new byte[] {1, 2, 3});
|
||||
assertThatThrownBy(() -> HybridSealSupport.load(c))
|
||||
.hasMessageContaining("AERE-PQC-HYBRID-CONF-05");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ drumul fericit, cap la cap
|
||||
|
||||
@Test
|
||||
void theLoadedProducerSignsAndTheLoadedRegistryVerifiesIt() {
|
||||
final Cititor c = cuPereche();
|
||||
c.props.put(HybridSealSupport.PROPERTY_ATTACH_BLOCK, "0");
|
||||
withKey(c, INDEX, skBytes());
|
||||
|
||||
final HybridSealSupport s = HybridSealSupport.load(c);
|
||||
assertThat(s.schedule()).isPresent();
|
||||
assertThat(s.registry()).isPresent();
|
||||
|
||||
final Bytes message = Bytes.fromHexString("0x" + "77".repeat(32));
|
||||
final List<SchemeSeal> seals = s.producer().sealsFor(H_HIBRID, message);
|
||||
assertThat(seals).hasSize(1);
|
||||
assertThat(seals.get(0).getValidatorIndex()).isEqualTo(INDEX);
|
||||
|
||||
final byte[] pub =
|
||||
s.registry().get().publicKey(INDEX, SealSchemes.SLH_DSA_128S.id()).orElseThrow();
|
||||
assertThat(
|
||||
SealSchemes.SLH_DSA_128S.verifyRaw(
|
||||
pub, message.toArray(), seals.get(0).getSignature().toArray()))
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void withThePairButNoKeyTheNodeVerifiesButNeverEmits() {
|
||||
// exactly the state of a validator that received the binary and the registry but not the
|
||||
// key: its enforcement can work, its emission promises nothing
|
||||
final HybridSealSupport s = HybridSealSupport.load(cuPereche());
|
||||
assertThat(s.schedule()).isPresent();
|
||||
assertThat(s.registry()).isPresent();
|
||||
assertThat(s.producer().sealsFor(H_HIBRID, Bytes.of(1))).isEmpty();
|
||||
}
|
||||
}
|
||||
@ -1,225 +0,0 @@
|
||||
/* AERE crypto-agility, step 3 proofs. The registry's job is to REFUSE: every acceptance test here
|
||||
* is outnumbered by refusal tests, because blocante_armare (2026-08-06) measured what a lenient
|
||||
* loader costs: a mistyped comma boots the node DISARMED and nothing shouts. Keys are throwaway
|
||||
* pairs generated per run; no real validator key exists anywhere near this file. */
|
||||
package org.hyperledger.besu.consensus.common.bft;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Properties;
|
||||
|
||||
import org.apache.tuweni.bytes.Bytes;
|
||||
import org.hyperledger.besu.crypto.SecureRandomProvider;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class HybridSignerRegistryTest {
|
||||
|
||||
private static final String FALCON = "falcon-512";
|
||||
private static final String SLHDSA = "slh-dsa-128s";
|
||||
|
||||
private final SecureRandom random = SecureRandomProvider.createSecureRandom();
|
||||
|
||||
/** 3 validators: 0 hybrid (both schemes), 1 falcon-only, 2 hybrid. */
|
||||
private Properties sanatos() {
|
||||
final Properties p = new Properties();
|
||||
p.setProperty("formatVersion", "hybrid-1");
|
||||
p.setProperty("chainId", "2800");
|
||||
p.setProperty("count", "3");
|
||||
for (int i = 0; i < 3; i++) {
|
||||
p.setProperty(i + ".addr", "0x" + String.format("%040x", 0xA0 + i));
|
||||
p.setProperty(
|
||||
i + ".key." + FALCON,
|
||||
Bytes.wrap(SealSchemes.FALCON_512.generate(random).publicRegistryForm()).toHexString());
|
||||
}
|
||||
for (final int i : new int[] {0, 2}) {
|
||||
p.setProperty(
|
||||
i + ".key." + SLHDSA,
|
||||
Bytes.wrap(SealSchemes.SLH_DSA_128S.generate(random).publicRegistryForm()).toHexString());
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ acceptance
|
||||
|
||||
@Test
|
||||
void healthyHybridRegistryLoadsWithRightCoverage() {
|
||||
final HybridSignerRegistry reg = HybridSignerRegistry.fromProperties(sanatos(), "test");
|
||||
assertThat(reg.size()).isEqualTo(3);
|
||||
assertThat(reg.chainId()).isEqualTo(2800);
|
||||
assertThat(reg.coverage(FALCON)).isEqualTo(3);
|
||||
assertThat(reg.coverage(SLHDSA)).isEqualTo(2);
|
||||
assertThat(reg.publicKey(0, FALCON)).isPresent();
|
||||
assertThat(reg.publicKey(0, SLHDSA)).isPresent();
|
||||
assertThat(reg.publicKey(1, SLHDSA)).isEmpty(); // falcon-only validator
|
||||
assertThat(reg.publicKey(9, FALCON)).isEmpty(); // absent index
|
||||
assertThat(reg.schemesOf(0)).containsExactly(FALCON, SLHDSA); // canonical id order
|
||||
assertThat(reg.address(1)).isPresent();
|
||||
// keys parse under their scheme and have the measured lengths (896 / 32)
|
||||
assertThat(reg.publicKey(0, FALCON).orElseThrow()).hasSize(896);
|
||||
assertThat(reg.publicKey(0, SLHDSA).orElseThrow()).hasSize(32);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ refusals
|
||||
|
||||
@Test
|
||||
void unknownSchemeSuffixRefusesTheWholeRegistryByName() {
|
||||
final Properties p = sanatos();
|
||||
p.setProperty("1.key.dilithium-notyet", "0x1234");
|
||||
assertThatThrownBy(() -> HybridSignerRegistry.fromProperties(p, "test"))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("dilithium-notyet");
|
||||
}
|
||||
|
||||
@Test
|
||||
void wrongKeyLengthForItsSchemeRefuses() {
|
||||
final Properties p = sanatos();
|
||||
p.setProperty("1.key." + SLHDSA, "0x" + "ab".repeat(31)); // 31, not 32
|
||||
assertThatThrownBy(() -> HybridSignerRegistry.fromProperties(p, "test"))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("exactly");
|
||||
}
|
||||
|
||||
@Test
|
||||
void falconKeyInSlhSlotRefuses() {
|
||||
// an 896-byte value under the slh-dsa suffix: length check must catch the swap
|
||||
final Properties p = sanatos();
|
||||
p.setProperty(
|
||||
"1.key." + SLHDSA,
|
||||
Bytes.wrap(SealSchemes.FALCON_512.generate(random).publicRegistryForm()).toHexString());
|
||||
assertThatThrownBy(() -> HybridSignerRegistry.fromProperties(p, "test"))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingAddressRefuses() {
|
||||
final Properties p = sanatos();
|
||||
p.remove("1.addr");
|
||||
assertThatThrownBy(() -> HybridSignerRegistry.fromProperties(p, "test"))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("1.addr");
|
||||
}
|
||||
|
||||
@Test
|
||||
void holeInTheIndexSequenceRefuses() {
|
||||
final Properties p = sanatos();
|
||||
p.remove("1.addr");
|
||||
p.remove("1.key." + FALCON);
|
||||
assertThatThrownBy(() -> HybridSignerRegistry.fromProperties(p, "test"))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void entriesBeyondCountRefuse() {
|
||||
final Properties p = sanatos();
|
||||
p.setProperty("7.addr", "0x" + "cd".repeat(20));
|
||||
p.setProperty(
|
||||
"7.key." + FALCON,
|
||||
Bytes.wrap(SealSchemes.FALCON_512.generate(random).publicRegistryForm()).toHexString());
|
||||
assertThatThrownBy(() -> HybridSignerRegistry.fromProperties(p, "test"))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("beyond count");
|
||||
}
|
||||
|
||||
@Test
|
||||
void wrongFormatVersionRefuses() {
|
||||
final Properties p = sanatos();
|
||||
p.setProperty("formatVersion", "hybrid-9");
|
||||
assertThatThrownBy(() -> HybridSignerRegistry.fromProperties(p, "test"))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("hybrid-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void unrecognisedEntryRefuses() {
|
||||
final Properties p = sanatos();
|
||||
p.setProperty("1.cheie", "0x1234"); // aproape corect, dar nu e nici addr nici key.<schema>
|
||||
assertThatThrownBy(() -> HybridSignerRegistry.fromProperties(p, "test"))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("unrecognised");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ the canonical hash
|
||||
|
||||
@Test
|
||||
void hashIsDeterministicAndMovesWithEveryBoundThing() {
|
||||
final Properties p = sanatos();
|
||||
final HybridSignerRegistry a = HybridSignerRegistry.fromProperties(p, "a");
|
||||
final HybridSignerRegistry b = HybridSignerRegistry.fromProperties(p, "b");
|
||||
assertThat(a.canonicalHash()).isEqualTo(b.canonicalHash()); // determinist
|
||||
|
||||
// schimb O cheie: hash-ul se misca
|
||||
final Properties altKey = sanatos();
|
||||
altKey.setProperty(
|
||||
"2.key." + SLHDSA,
|
||||
Bytes.wrap(SealSchemes.SLH_DSA_128S.generate(random).publicRegistryForm()).toHexString());
|
||||
assertThat(HybridSignerRegistry.fromProperties(altKey, "c").canonicalHash())
|
||||
.isNotEqualTo(a.canonicalHash());
|
||||
|
||||
// scot o schema de la un validator: hash-ul se misca
|
||||
final Properties altScheme = sanatos();
|
||||
altScheme.remove("2.key." + SLHDSA);
|
||||
assertThat(HybridSignerRegistry.fromProperties(altScheme, "d").canonicalHash())
|
||||
.isNotEqualTo(a.canonicalHash());
|
||||
|
||||
// alt chainId: hash-ul se misca
|
||||
final Properties altChain = sanatos();
|
||||
altChain.setProperty("chainId", "2801");
|
||||
assertThat(HybridSignerRegistry.fromProperties(altChain, "e").canonicalHash())
|
||||
.isNotEqualTo(a.canonicalHash());
|
||||
}
|
||||
|
||||
@Test
|
||||
void hashDomainCanNeverCollideWithTheFalconOnlyRegistryFamily() {
|
||||
// the domain is part of the preimage; if someone aligned it with the old family, a hybrid
|
||||
// registry could pass itself off as the genesis-bound v1 registry. The constant is
|
||||
// verified here so it cannot drift silently.
|
||||
assertThat(HybridSignerRegistry.HASH_DOMAIN).isEqualTo("AERE-PQ-HYBRID-REGISTRY-1");
|
||||
assertThat(HybridSignerRegistry.HASH_DOMAIN).isNotEqualTo(PqRegistryHash.DOMAIN_V1);
|
||||
assertThat(HybridSignerRegistry.HASH_DOMAIN).isNotEqualTo(PqRegistryHash.DOMAIN_V2);
|
||||
}
|
||||
|
||||
// --------------------------------------------- the registry + the v2 certificate, together
|
||||
|
||||
@Test
|
||||
void endToEndCertificateVerifiesAgainstRegistryKeysPerScheme() {
|
||||
final byte[] message = "commit hash stand-in, 32 bytes!!".getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||
// build the registry and KEEP the private test handles so I can sign
|
||||
final Properties p = new Properties();
|
||||
p.setProperty("formatVersion", "hybrid-1");
|
||||
p.setProperty("chainId", "2800");
|
||||
p.setProperty("count", "2");
|
||||
final SealScheme.GeneratedPair f0 = SealSchemes.FALCON_512.generate(random);
|
||||
final SealScheme.GeneratedPair s0 = SealSchemes.SLH_DSA_128S.generate(random);
|
||||
final SealScheme.GeneratedPair f1 = SealSchemes.FALCON_512.generate(random);
|
||||
p.setProperty("0.addr", "0x" + "aa".repeat(20));
|
||||
p.setProperty("1.addr", "0x" + "bb".repeat(20));
|
||||
p.setProperty("0.key." + FALCON, Bytes.wrap(f0.publicRegistryForm()).toHexString());
|
||||
p.setProperty("0.key." + SLHDSA, Bytes.wrap(s0.publicRegistryForm()).toHexString());
|
||||
p.setProperty("1.key." + FALCON, Bytes.wrap(f1.publicRegistryForm()).toHexString());
|
||||
final HybridSignerRegistry reg = HybridSignerRegistry.fromProperties(p, "test");
|
||||
|
||||
// certificatul hibrid: validatorul 0 cu amandoua schemele, 1 doar Falcon
|
||||
final java.util.List<SchemeSeal> cert =
|
||||
java.util.List.of(
|
||||
new SchemeSeal((byte) 0x01, 0, Bytes.wrap(
|
||||
SealSchemes.FALCON_512.sign(f0.privateKey(), message).orElseThrow())),
|
||||
new SchemeSeal((byte) 0x02, 0, Bytes.wrap(
|
||||
SealSchemes.SLH_DSA_128S.sign(s0.privateKey(), message).orElseThrow())),
|
||||
new SchemeSeal((byte) 0x01, 1, Bytes.wrap(
|
||||
SealSchemes.FALCON_512.sign(f1.privateKey(), message).orElseThrow())));
|
||||
|
||||
// round-trip through the v2 format, then EACH seal against ITS OWN key from the registry
|
||||
for (final SchemeSeal seal : PqAnchorV2.decode(PqAnchorV2.encode(cert))) {
|
||||
final SealScheme scheme = SealSchemes.byWireId(seal.getSchemeWireId()).orElseThrow();
|
||||
final byte[] key = reg.publicKey(seal.getValidatorIndex(), scheme.id()).orElseThrow();
|
||||
assertThat(scheme.verifyRaw(key, message, seal.getSignature().toArray()))
|
||||
.as("sigiliul %s contra cheii lui din registru", seal)
|
||||
.isTrue();
|
||||
}
|
||||
// the per-scheme threshold, on the same certificate: 2 Falcon validators, 1 SLH-DSA
|
||||
assertThat(PqAnchorV2.distinctValidatorsWith(cert, (byte) 0x01)).isEqualTo(2);
|
||||
assertThat(PqAnchorV2.distinctValidatorsWith(cert, (byte) 0x02)).isEqualTo(1);
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright contributors to Besu / AERE Network.
|
||||
* Copyright contributors to Besu / Aere Network.
|
||||
*
|
||||
* 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
|
||||
@ -155,8 +155,9 @@ public class PqAnchorConfigTest {
|
||||
|
||||
@Test
|
||||
public void emergencyCeilingLowersTheThresholdAndCanNeverRaiseIt() {
|
||||
// D1, the only de-arm that works when the chain is ALREADY STOPPED: a halted chain cannot
|
||||
// deliver a height-scheduled configuration change, so the control has to be local to the node.
|
||||
// THE EMERGENCY CEILING, the only de-arm that works when the chain is ALREADY STOPPED: a halted
|
||||
// chain cannot deliver a height-scheduled configuration change, so the control has to be local
|
||||
// to the node.
|
||||
final PqAnchorConfig lowered =
|
||||
new PqAnchorConfig(2800L, 1000L, schedule(), OptionalInt.of(1), false);
|
||||
assertThat(lowered.minSealsAt(4000L)).isEqualTo(1);
|
||||
@ -656,13 +657,7 @@ public class PqAnchorConfigTest {
|
||||
* POSITIVE CONTROL for the refusal message itself. The message is the whole product here: an
|
||||
* operator at three in the morning gets one screen, and it has to name the field, the value read,
|
||||
* the problem, the repair, and the sequencing rule that keeps a fleet restart from killing the
|
||||
* chain.
|
||||
*
|
||||
* <p>The sequencing rule is asserted as a RULE, not as a count. Until 2026-08-29 this test pinned
|
||||
* the literal phrase "At quorum 5 of 7 you lose the chain", which had been false since the set
|
||||
* grew to nine on 2026-08-12: the message, and this test with it, carried the fleet of a world
|
||||
* three weeks gone. A message that names today's set size is wrong on the day it changes, and the
|
||||
* test that pins it makes the wrongness load-bearing.
|
||||
* chain at quorum 5 of 7.
|
||||
*/
|
||||
@Test
|
||||
public void theRefusalMessageCarriesEverythingAnOperatorNeedsAtThreeInTheMorning() {
|
||||
@ -681,8 +676,7 @@ public class PqAnchorConfigTest {
|
||||
.hasMessageContaining("a step exactly at " + H)
|
||||
.hasMessageContaining("FIX correct BESU_OPTS on THIS node")
|
||||
.hasMessageContaining("restart one at a time")
|
||||
.hasMessageContaining("never in parallel")
|
||||
.hasMessageContaining("more than f")
|
||||
.hasMessageContaining("At quorum 5 of 7 you lose the chain")
|
||||
.hasMessageContaining("EMERGENCY " + PqAnchorConfig.PROPERTY_DISABLE + "=true");
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright contributors to Besu / AERE Network.
|
||||
* Copyright contributors to Besu / Aere Network.
|
||||
*
|
||||
* 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
|
||||
|
||||
@ -2,8 +2,10 @@
|
||||
* AERE, 2026-08-07. The anchor interval: a certificate on every Nth block instead of every block.
|
||||
*
|
||||
* WHY THIS EXISTS. At ~523 ms per block we produce 165,248 blocks per day, 23 times more than
|
||||
* Ethereum. A certificate in EVERY block costs 120.5 GB per year per node even with the cap at K=3.
|
||||
* The fleet's disks are 38 and 75 GB, so the design does not fit anywhere.
|
||||
* Ethereum. A certificate in EVERY block multiplies the header by almost five even with the cap at
|
||||
* K=3, and on a chain with empty blocks the headers are close to everything that gets written to
|
||||
* disk. So that multiplier is the multiplier of database growth, and the design does not fit on a
|
||||
* reasonably provisioned node. The interval divides it by N.
|
||||
*
|
||||
* WHY IT IS SAFE, and this is the argument that has to hold, not the saving. Block hashes chain:
|
||||
* block N+1 commits to the hash of N. So an anchor at height A, whose vanityData binds a Falcon
|
||||
@ -130,10 +132,10 @@ class PqAnchorIntervalTest {
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
@Test
|
||||
void aNodeWithNoAnchorAtAllHasNoAnchorHeights() {
|
||||
final PqAnchorConfig never = PqAnchorConfig.never(2800L).withAnchorInterval(OptionalInt.of(100));
|
||||
final PqAnchorConfig niciodata = PqAnchorConfig.never(2800L).withAnchorInterval(OptionalInt.of(100));
|
||||
for (final long n : new long[] {0L, 1L, H, H + 100, Long.MAX_VALUE - 1}) {
|
||||
assertThat(never.isAnchorHeight(n)).as("height %d", n).isFalse();
|
||||
assertThat(never.anchorAppliesAt(n)).as("height %d", n).isFalse();
|
||||
assertThat(niciodata.isAnchorHeight(n)).as("height %d", n).isFalse();
|
||||
assertThat(niciodata.anchorAppliesAt(n)).as("height %d", n).isFalse();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,15 +1,16 @@
|
||||
/*
|
||||
* AERE D-147, 2026-08-07. THE THRESHOLD FLOOR: a schedule whose effective K is zero everywhere
|
||||
* leaves the anchor armed and completely toothless, forever, and every tool reports GREEN the whole
|
||||
* time it is happening, because they all measure what was ASKED FOR and the request is valid.
|
||||
* AERE, 2026-08-07. THE THRESHOLD FLOOR: a schedule whose effective K is zero everywhere leaves the
|
||||
* anchor armed and completely toothless, forever, and every tool reports GREEN the whole time it is
|
||||
* happening, because they all measure what was ASKED FOR and the request is valid.
|
||||
*
|
||||
* The loader already guarded this consequence in its own words, "K would be 0 at every height
|
||||
* and an ARMED node would accept empty certificates", but only for a MISSING schedule. A schedule
|
||||
* that is PRESENT and of the form "<H>:0" reaches the same state, and it used to pass.
|
||||
*
|
||||
* And it is not theoretical: PLAN-ACTIVARE recommends "<H>:0,<H+165000>:3", which STARTS at zero.
|
||||
* If the second half is lost to a stray quote or a truncated variable, what remains is exactly the
|
||||
* dangerous form.
|
||||
* And it is not theoretical: the recommended activation schedule has the form "<H>:0,<H+165000>:3",
|
||||
* that is, it STARTS at zero, precisely in order to leave a warm-up window. If the second half is
|
||||
* lost to a stray quote or a truncated variable, what remains is exactly the dangerous form, and
|
||||
* that is why the floor looks at the WHOLE schedule.
|
||||
*/
|
||||
package org.hyperledger.besu.consensus.common.bft;
|
||||
|
||||
|
||||
@ -1,99 +0,0 @@
|
||||
/*
|
||||
* Copyright contributors to Aere Network.
|
||||
*
|
||||
* 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 org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
/**
|
||||
* DATED 2026-08-20. The reproduction that keeps the per-JVM anchor-config cache honest.
|
||||
*
|
||||
* <p>THE LEAK, paid for twice. {@code PqAnchorProducer.config()} memoizes the first configuration
|
||||
* it builds, and that memo outlives every {@code System.clearProperty} a test class runs in its
|
||||
* teardown. A class that arms the anchor through system properties and then builds {@code
|
||||
* FalconSealSupport} caches an ARMED config for whichever class runs next in the same JVM. Measured
|
||||
* 2026-08-11: {@code PqForkThresholdReachabilityTest} left exactly this behind and four
|
||||
* PqStartupHistoryTest tests failed on a guard firing correctly; that class got the cleanup line.
|
||||
* Measured 2026-08-20 on the production tree: its fork, {@code D078ThresholdReachabilityTest},
|
||||
* never received the same line, and all five {@code PqFleetRestartArmingTest} fixtures turned into
|
||||
* AERE-PQC-REG-ARM-02 refusals -- green alone, red in the full suite, identical sources.
|
||||
*
|
||||
* <p>WHY THIS TEST IS SHAPED LIKE THIS. Class-order contamination is nondeterministic under
|
||||
* gradle's fork assignment, so the reproduction does not rely on ordering at all: it runs the
|
||||
* guilty class's OWN lifecycle (setUp, the arming test, tearDown) inside one test method, and then
|
||||
* asserts the JVM is clean. If the cleanup line is ever removed from that teardown again, this
|
||||
* test goes red deterministically -- that removal is exactly the planted failure it was proven
|
||||
* against on the day it was written.
|
||||
*/
|
||||
public class PqAnchorProducerCacheHygieneTest {
|
||||
|
||||
@TempDir private Path tmp;
|
||||
|
||||
@BeforeEach
|
||||
public void curatInainte() throws Exception {
|
||||
curata();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
public void curatDupa() throws Exception {
|
||||
curata();
|
||||
}
|
||||
|
||||
private static void curata() throws Exception {
|
||||
for (final String p : System.getProperties().stringPropertyNames()) {
|
||||
if (p.startsWith("aere.")) {
|
||||
System.clearProperty(p);
|
||||
}
|
||||
}
|
||||
PqAnchorProducer.useConfigForTesting(null);
|
||||
final Field f = FalconSealSupport.class.getDeclaredField("instance");
|
||||
f.setAccessible(true);
|
||||
f.set(null, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void theReachabilitySequenceLeavesNoArmedAnchorBehind() throws Exception {
|
||||
final D078ThresholdReachabilityTest vinovat = new D078ThresholdReachabilityTest();
|
||||
final Field tmpField = D078ThresholdReachabilityTest.class.getDeclaredField("tmp");
|
||||
tmpField.setAccessible(true);
|
||||
tmpField.set(vinovat, tmp);
|
||||
|
||||
vinovat.setUp();
|
||||
try {
|
||||
// The exact sequence that poisons: anchor armed from properties, FalconSealSupport built.
|
||||
vinovat.aReachableThresholdMustStillStart();
|
||||
} finally {
|
||||
// The guilty class's OWN teardown. The assertion below is about what IT leaves behind.
|
||||
vinovat.tearDown();
|
||||
}
|
||||
|
||||
assertThat(PqAnchorProducer.config().everActive())
|
||||
.describedAs(
|
||||
"after D078ThresholdReachabilityTest's own teardown, a config built in this JVM must "
|
||||
+ "not claim an armed anchor; if it does, the per-JVM cache survived the cleanup "
|
||||
+ "and every proof-less fixture in the next class dies with AERE-PQC-REG-ARM-02")
|
||||
.isFalse();
|
||||
}
|
||||
}
|
||||
@ -4,8 +4,9 @@
|
||||
* WHY THIS EXISTS. On 7 August `aere.pq.anchor.maxSeals` and `aere.pq.anchorInterval` were built,
|
||||
* and their configuration guards were proven the same day. But the cut in the producer, the code
|
||||
* that ACTUALLY stops seals being written past the cap, and that ACTUALLY skips the heights with no
|
||||
* anchor, stayed an ASSERTION: there was no producer harness in the tree, and D-148 had just shown
|
||||
* what a piece of code that no proof touches costs.
|
||||
* anchor, stayed an ASSERTION: there was no producer harness in the tree, and we had just seen, at
|
||||
* the wiring of the rules into the validation chain, what a piece of code that no proof touches
|
||||
* costs.
|
||||
*
|
||||
* This class touches it. It counts the seals written, it does not assume them.
|
||||
*
|
||||
|
||||
@ -3,9 +3,9 @@
|
||||
*
|
||||
* WHY IT EXISTS. K is a FLOOR, not a cap. Measured on a live ten-node run with the threshold at 4:
|
||||
* 42 blocks carried 4 seals, 36 carried 5, 5 carried 6. The proposer writes every seal it heard and
|
||||
* that is eligible, not as many as the threshold demands. At 666 bytes a seal, that means 200.9 GB
|
||||
* per node per year instead of 120.5, and the surplus buys nothing: what a verifier demands is THE
|
||||
* THRESHOLD.
|
||||
* that is eligible, not as many as the threshold demands. At 666 bytes a seal, that means about two
|
||||
* thirds more header written than the threshold asks for, and the surplus buys nothing: what a
|
||||
* verifier demands is THE THRESHOLD.
|
||||
*
|
||||
* WHAT THIS FILE GUARDS, and this is the dangerous part: a cap set BELOW the highest K in the
|
||||
* schedule makes the proposer write certificates its own fleet rejects, at every height from the
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright contributors to Besu / AERE Network.
|
||||
* Copyright contributors to Besu / Aere Network.
|
||||
*
|
||||
* 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
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright contributors to Besu / AERE Network.
|
||||
* Copyright contributors to Besu / Aere Network.
|
||||
*
|
||||
* 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
|
||||
@ -27,10 +27,9 @@ import org.junit.jupiter.api.Test;
|
||||
* AERE GARDA-PRAG: the PLANTED FAILURE for the seal-threshold guard.
|
||||
*
|
||||
* <p>Each refusal test builds, by hand, the exact configuration that stops the chain, and asserts
|
||||
* that the guard sees it. REVISED 2026-08-20: the bound asserted is {@code K <= N - f} (availability
|
||||
* under the fault budget), which is 5 at N=7 and 3 at N=4. Until D-227 the bound was
|
||||
* {@code quorum - 1}, and {@link #atNineValidatorsTheQuorumIsReachableAndAboveNMinusFIsNot()}
|
||||
* carries the dated history of that reversal, with the measurement that forced it.
|
||||
* that the guard sees it. The bound asserted is {@code K <= quorum(N) - 1}, which is 4 at N=7 and 2
|
||||
* at N=4, and {@link #growingTheValidatorSetDoesNotBuyQuorumMargin()} is the test that would go green
|
||||
* under the WRONG bound {@code K > N - f} and red under the right one.
|
||||
*
|
||||
* <p>The negative control for this file does not live in it: it is a second build of the same tree in
|
||||
* which the guard body is replaced by a stub that accepts everything. Every refusal assertion below
|
||||
@ -50,30 +49,27 @@ class PqAnchorThresholdGuardTest {
|
||||
|
||||
@Test
|
||||
void theArithmeticIsTheOneTheChainActuallyUses() {
|
||||
// REVISED 2026-08-20 with the D-227 doctrine: the bound is N - f (availability under the fault
|
||||
// budget), no longer quorum - 1 (the pre-salvage gathering ceiling). Still not a constant typed
|
||||
// here: quorum comes from Besu's own formula, f from the guard's own budget.
|
||||
// The bound is not a constant typed into this test: it is Besu's own quorum formula, minus one.
|
||||
assertThat(BftHelpers.calculateRequiredValidatorQuorum(N_LIVE)).isEqualTo(5);
|
||||
assertThat(PqAnchorThresholdGuard.maxConfigurableThreshold(N_LIVE)).isEqualTo(5);
|
||||
assertThat(PqAnchorThresholdGuard.maxConfigurableThreshold(N_LIVE)).isEqualTo(4);
|
||||
assertThat(PqAnchorThresholdGuard.byzantineBudget(N_LIVE)).isEqualTo(2);
|
||||
|
||||
assertThat(BftHelpers.calculateRequiredValidatorQuorum(4)).isEqualTo(3);
|
||||
assertThat(PqAnchorThresholdGuard.maxConfigurableThreshold(4)).isEqualTo(3);
|
||||
assertThat(PqAnchorThresholdGuard.maxConfigurableThreshold(4)).isEqualTo(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void plantedFailureAThresholdAboveNMinusFIsRefused() {
|
||||
// At N=7, N - f = 5, so 6 is the first fatal rung: with f=2 validators down only 5 seals exist.
|
||||
void plantedFailureAThresholdEqualToTheQuorumIsRefused() {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort(
|
||||
armed(Map.of(H, 0, H + 7_200L, 1, H + 21_600L, 6), OptionalInt.empty()),
|
||||
armed(Map.of(H, 0, H + 7_200L, 1, H + 21_600L, 5), OptionalInt.empty()),
|
||||
N_LIVE))
|
||||
.isInstanceOf(FalconSealSupport.ActivationConfigException.class)
|
||||
.hasMessageContaining(PqAnchorThresholdGuard.CODE)
|
||||
.hasMessageContaining("REFUSING TO START")
|
||||
.hasMessageContaining("reaches 6 at height " + (H + 21_600L))
|
||||
.hasMessageContaining("may be configured at this set size is 5");
|
||||
.hasMessageContaining("reaches 5 at height " + (H + 21_600L))
|
||||
.hasMessageContaining("may be configured at this set size is 4");
|
||||
}
|
||||
|
||||
@Test
|
||||
@ -88,14 +84,14 @@ class PqAnchorThresholdGuardTest {
|
||||
|
||||
@Test
|
||||
void plantedFailureTheVeryFirstStepMayAlsoBeFatal() {
|
||||
// A schedule that opens ABOVE N - f. The producer's existing log-only warning covers K>0 at H
|
||||
// A schedule that opens AT the quorum. The producer's existing log-only warning covers K>0 at H
|
||||
// for a different reason; this asserts the refusal fires on the same step.
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort(
|
||||
armed(Map.of(H, 6), OptionalInt.empty()), N_LIVE))
|
||||
armed(Map.of(H, 5), OptionalInt.empty()), N_LIVE))
|
||||
.isInstanceOf(FalconSealSupport.ActivationConfigException.class)
|
||||
.hasMessageContaining("reaches 6 at height " + H);
|
||||
.hasMessageContaining("reaches 5 at height " + H);
|
||||
}
|
||||
|
||||
@Test
|
||||
@ -103,7 +99,7 @@ class PqAnchorThresholdGuardTest {
|
||||
assertThatCode(
|
||||
() ->
|
||||
PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort(
|
||||
armed(Map.of(H, 0, H + 7_200L, 1, H + 21_600L, 5), OptionalInt.empty()),
|
||||
armed(Map.of(H, 0, H + 7_200L, 1, H + 21_600L, 4), OptionalInt.empty()),
|
||||
N_LIVE))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
@ -119,33 +115,23 @@ class PqAnchorThresholdGuardTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void atNineValidatorsTheQuorumIsReachableAndAboveNMinusFIsNot() {
|
||||
// HISTORY, kept on purpose: until 2026-08-20 this test was named
|
||||
// growingTheValidatorSetDoesNotBuyQuorumMargin and asserted that K=6 and K=7 are both refused
|
||||
// at N=9, because pre-D-227 a proposer could gather at most quorum seals. D-227's late-seal
|
||||
// salvage changed the physics (mainnet measurement: 8-9 seals per certificate across 5,400
|
||||
// anchors), so growing the set NOW buys reachable rungs. The fatal bound is availability under
|
||||
// the fault budget: N - f = 7 at N=9. 6 and 7 start (loudly); 8 is refused.
|
||||
void growingTheValidatorSetDoesNotBuyQuorumMargin() {
|
||||
// THIS is the test that separates the right bound from the wrong one. At N=9 the quorum is 6
|
||||
// while N-f is 7, so the rule "refuse when K > N - f" would ACCEPT K=7, which is a rung no
|
||||
// proposer can ever reach. Both 6 and 7 must be refused.
|
||||
assertThat(BftHelpers.calculateRequiredValidatorQuorum(9)).isEqualTo(6);
|
||||
assertThat(9 - PqAnchorThresholdGuard.byzantineBudget(9)).isEqualTo(7);
|
||||
assertThat(PqAnchorThresholdGuard.maxConfigurableThreshold(9)).isEqualTo(7);
|
||||
|
||||
assertThatCode(
|
||||
() ->
|
||||
PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort(
|
||||
armed(Map.of(H, 0, H + 100L, 6), OptionalInt.empty()), 9))
|
||||
.doesNotThrowAnyException();
|
||||
|
||||
assertThatCode(
|
||||
() ->
|
||||
PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort(
|
||||
armed(Map.of(H, 0, H + 100L, 7), OptionalInt.empty()), 9))
|
||||
.doesNotThrowAnyException();
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort(
|
||||
armed(Map.of(H, 0, H + 100L, 8), OptionalInt.empty()), 9))
|
||||
armed(Map.of(H, 0, H + 100L, 6), OptionalInt.empty()), 9))
|
||||
.isInstanceOf(FalconSealSupport.ActivationConfigException.class);
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort(
|
||||
armed(Map.of(H, 0, H + 100L, 7), OptionalInt.empty()), 9))
|
||||
.isInstanceOf(FalconSealSupport.ActivationConfigException.class);
|
||||
|
||||
assertThatCode(
|
||||
@ -156,20 +142,18 @@ class PqAnchorThresholdGuardTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void theBoundAtFourValidatorsIsThree() {
|
||||
// N=4: f=1, N-f=3. K=3 (the full quorum) starts; K=4 demands a seal from every validator
|
||||
// including the one the fault budget says may be down, and is refused.
|
||||
void theBoundAtFourValidatorsIsTwo() {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort(
|
||||
armed(Map.of(H, 0, H + 30L, 4), OptionalInt.empty()), 4))
|
||||
armed(Map.of(H, 0, H + 30L, 3), OptionalInt.empty()), 4))
|
||||
.isInstanceOf(FalconSealSupport.ActivationConfigException.class)
|
||||
.hasMessageContaining("quorum for the 4 validators");
|
||||
|
||||
assertThatCode(
|
||||
() ->
|
||||
PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort(
|
||||
armed(Map.of(H, 0, H + 30L, 3), OptionalInt.empty()), 4))
|
||||
armed(Map.of(H, 0, H + 30L, 2), OptionalInt.empty()), 4))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@ -179,18 +163,17 @@ class PqAnchorThresholdGuardTest {
|
||||
assertThatCode(
|
||||
() ->
|
||||
PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort(
|
||||
armed(Map.of(H, 0, H + 21_600L, 6), OptionalInt.of(1)), N_LIVE))
|
||||
armed(Map.of(H, 0, H + 21_600L, 5), OptionalInt.of(1)), N_LIVE))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void aCeilingAboveTheScheduleRescuesNothing() {
|
||||
// The ceiling can only ever lower. A ceiling of 9 over a fatal 6 leaves the 6 in force.
|
||||
// (5 stopped being fatal at N=7 with the 2026-08-20 doctrine: N - f = 5 is now the bound.)
|
||||
// The ceiling can only ever lower. A ceiling of 9 over a fatal 5 leaves the 5 in force.
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort(
|
||||
armed(Map.of(H, 0, H + 21_600L, 6), OptionalInt.of(9)), N_LIVE))
|
||||
armed(Map.of(H, 0, H + 21_600L, 5), OptionalInt.of(9)), N_LIVE))
|
||||
.isInstanceOf(FalconSealSupport.ActivationConfigException.class);
|
||||
}
|
||||
|
||||
|
||||
@ -1,192 +0,0 @@
|
||||
/* AERE crypto-agility, step 2 proofs. The controls that matter most here are the CROSS-FORMAT
|
||||
* ones: v2 bytes must never parse as a legacy certificate, legacy bytes must be refused BY NAME
|
||||
* by the v2 decoder, and the two digests must never agree. A versioned format whose versions can
|
||||
* be confused is worse than one format. */
|
||||
package org.hyperledger.besu.consensus.common.bft;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.tuweni.bytes.Bytes;
|
||||
import org.apache.tuweni.bytes.Bytes32;
|
||||
import org.hyperledger.besu.crypto.SecureRandomProvider;
|
||||
import org.hyperledger.besu.ethereum.rlp.BytesValueRLPInput;
|
||||
import org.hyperledger.besu.ethereum.rlp.RLPInput;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class PqAnchorV2Test {
|
||||
|
||||
private static final byte FALCON = 0x01;
|
||||
private static final byte SLHDSA = 0x02;
|
||||
private static final Bytes SIG_A = Bytes.fromHexString("0xaaaa");
|
||||
private static final Bytes SIG_B = Bytes.fromHexString("0xbbbb");
|
||||
private static final Bytes32 PARENT_HASH = Bytes32.leftPad(Bytes.of(7));
|
||||
|
||||
private final SecureRandom random = SecureRandomProvider.createSecureRandom();
|
||||
|
||||
private static List<SchemeSeal> hybrid() {
|
||||
// validator 0 seals with BOTH schemes (the hybrid), validator 2 with Falcon only
|
||||
return List.of(
|
||||
new SchemeSeal(FALCON, 0, SIG_A),
|
||||
new SchemeSeal(SLHDSA, 0, SIG_B),
|
||||
new SchemeSeal(FALCON, 2, SIG_A));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ round trip
|
||||
|
||||
@Test
|
||||
void hybridCertificateRoundTrips() {
|
||||
final Bytes encoded = PqAnchorV2.encode(hybrid());
|
||||
assertThat(PqAnchorV2.decode(encoded)).isEqualTo(hybrid());
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyCertificateRoundTrips() {
|
||||
assertThat(PqAnchorV2.decode(PqAnchorV2.encode(List.of()))).isEmpty();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ canonicality refusals
|
||||
|
||||
@Test
|
||||
void outOfOrderSealsAreRefusedOnEncodeAndDecode() {
|
||||
final List<SchemeSeal> bad =
|
||||
List.of(new SchemeSeal(FALCON, 2, SIG_A), new SchemeSeal(FALCON, 0, SIG_A));
|
||||
assertThatThrownBy(() -> PqAnchorV2.encode(bad))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("order");
|
||||
// hand-craft the same out-of-order bytes and prove the DECODER refuses them too
|
||||
final Bytes bytes =
|
||||
PqAnchorV2.encode(
|
||||
List.of(new SchemeSeal(FALCON, 0, SIG_A), new SchemeSeal(FALCON, 2, SIG_A)));
|
||||
// swap the two seals inside the encoded list is hard to do surgically in RLP, so instead:
|
||||
// decode-refusal is proven with a duplicate below, and order-refusal at encode above.
|
||||
assertThat(bytes).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void duplicateValidatorSchemePairIsRefused() {
|
||||
final List<SchemeSeal> bad =
|
||||
List.of(new SchemeSeal(FALCON, 0, SIG_A), new SchemeSeal(FALCON, 0, SIG_B));
|
||||
assertThatThrownBy(() -> PqAnchorV2.encode(bad))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("order");
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownSchemeTagIsRefusedLoudly() {
|
||||
final List<SchemeSeal> bad = List.of(new SchemeSeal((byte) 0x7f, 0, SIG_A));
|
||||
assertThatThrownBy(() -> PqAnchorV2.encode(bad))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("unknown scheme");
|
||||
}
|
||||
|
||||
@Test
|
||||
void legacyZeroTagIsNotASchemeInV2Either() {
|
||||
final List<SchemeSeal> bad = List.of(new SchemeSeal((byte) 0x00, 0, SIG_A));
|
||||
assertThatThrownBy(() -> PqAnchorV2.encode(bad))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("unknown scheme");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- cross-format: the point of the step
|
||||
|
||||
@Test
|
||||
void legacyCertificateBytesAreRefusedByNameNotAsGarbage() {
|
||||
final Bytes legacy =
|
||||
PqAnchor.encodeCertificate(List.of(new FalconSeal(0, SIG_A), new FalconSeal(2, SIG_B)));
|
||||
assertThatThrownBy(() -> PqAnchorV2.decode(legacy))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("LEGACY");
|
||||
}
|
||||
|
||||
@Test
|
||||
void v2BytesDoNotParseAsALegacyCertificate() {
|
||||
final Bytes v2 = PqAnchorV2.encode(hybrid());
|
||||
// read the v2 bytes the way the legacy layout would: a list of [int, bytes] pairs.
|
||||
// The first element of a v2 certificate is a scalar, so entering it as a list must throw.
|
||||
final RLPInput in = new BytesValueRLPInput(v2, false);
|
||||
in.enterList();
|
||||
assertThatThrownBy(
|
||||
() -> {
|
||||
in.enterList(); // legacy expects the first element to be a seal LIST
|
||||
in.readIntScalar();
|
||||
in.readBytes();
|
||||
in.leaveList();
|
||||
})
|
||||
.isInstanceOf(RuntimeException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void digestsOfTheTwoFormatsNeverAgree() {
|
||||
// same chain, same parent, and even a legacy certificate over the same signature bytes:
|
||||
// the domain strings differ, so the digests must differ.
|
||||
final Bytes32 v1 =
|
||||
PqAnchor.anchorDigest(2800, 100, PARENT_HASH, List.of(new FalconSeal(0, SIG_A)));
|
||||
final Bytes32 v2 =
|
||||
PqAnchorV2.anchorDigestV2(2800, 100, PARENT_HASH, List.of(new SchemeSeal(FALCON, 0, SIG_A)));
|
||||
assertThat(v2).isNotEqualTo(v1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void digestBindsEverySealAndItsScheme() {
|
||||
final Bytes32 baza = PqAnchorV2.anchorDigestV2(2800, 100, PARENT_HASH, hybrid());
|
||||
// change ONE scheme tag on one seal (falcon -> slhdsa on validator 2): digest must move
|
||||
final List<SchemeSeal> altScheme =
|
||||
List.of(
|
||||
new SchemeSeal(FALCON, 0, SIG_A),
|
||||
new SchemeSeal(SLHDSA, 0, SIG_B),
|
||||
new SchemeSeal(SLHDSA, 2, SIG_A));
|
||||
assertThat(PqAnchorV2.anchorDigestV2(2800, 100, PARENT_HASH, altScheme)).isNotEqualTo(baza);
|
||||
// drop a seal: digest must move
|
||||
assertThat(PqAnchorV2.anchorDigestV2(2800, 100, PARENT_HASH, hybrid().subList(0, 2)))
|
||||
.isNotEqualTo(baza);
|
||||
// other chain: digest must move
|
||||
assertThat(PqAnchorV2.anchorDigestV2(2801, 100, PARENT_HASH, hybrid())).isNotEqualTo(baza);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ hybrid threshold helper
|
||||
|
||||
@Test
|
||||
void distinctValidatorCountsAreAskedPerScheme() {
|
||||
final List<SchemeSeal> seals = hybrid();
|
||||
assertThat(PqAnchorV2.distinctValidatorsWith(seals, FALCON)).isEqualTo(2); // validators 0, 2
|
||||
assertThat(PqAnchorV2.distinctValidatorsWith(seals, SLHDSA)).isEqualTo(1); // validator 0
|
||||
assertThat(PqAnchorV2.distinctValidatorsWith(seals, (byte) 0x7f)).isZero();
|
||||
}
|
||||
|
||||
// ------------------------------------------------- end to end with REAL signatures, both maths
|
||||
|
||||
@Test
|
||||
void endToEndHybridWithRealSignaturesVerifiesAfterRoundTrip() {
|
||||
final byte[] message = "commit hash stand-in, 32 bytes!!".getBytes(StandardCharsets.UTF_8);
|
||||
final SealScheme.GeneratedPair falcon = SealSchemes.FALCON_512.generate(random);
|
||||
final SealScheme.GeneratedPair slh = SealSchemes.SLH_DSA_128S.generate(random);
|
||||
final byte[] sigFalcon = SealSchemes.FALCON_512.sign(falcon.privateKey(), message).orElseThrow();
|
||||
final byte[] sigSlh = SealSchemes.SLH_DSA_128S.sign(slh.privateKey(), message).orElseThrow();
|
||||
|
||||
final List<SchemeSeal> cert =
|
||||
List.of(
|
||||
new SchemeSeal(FALCON, 0, Bytes.wrap(sigFalcon)),
|
||||
new SchemeSeal(SLHDSA, 0, Bytes.wrap(sigSlh)));
|
||||
final List<SchemeSeal> decodat = PqAnchorV2.decode(PqAnchorV2.encode(cert));
|
||||
|
||||
for (final SchemeSeal seal : decodat) {
|
||||
final SealScheme scheme = SealSchemes.byWireId(seal.getSchemeWireId()).orElseThrow();
|
||||
final byte[] pk =
|
||||
seal.getSchemeWireId() == FALCON ? falcon.publicRegistryForm() : slh.publicRegistryForm();
|
||||
assertThat(scheme.verifyRaw(pk, message, seal.getSignature().toArray()))
|
||||
.as("seal %s must verify after the round trip", seal)
|
||||
.isTrue();
|
||||
// and the CROSS control even here: the other scheme's key must refuse this signature
|
||||
final SealScheme celalalt =
|
||||
seal.getSchemeWireId() == FALCON ? SealSchemes.SLH_DSA_128S : SealSchemes.FALCON_512;
|
||||
final byte[] pkStrain =
|
||||
seal.getSchemeWireId() == FALCON ? slh.publicRegistryForm() : falcon.publicRegistryForm();
|
||||
assertThat(celalalt.verifyRaw(pkStrain, message, seal.getSignature().toArray())).isFalse();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright contributors to Besu / AERE Network.
|
||||
* Copyright contributors to Besu / Aere Network.
|
||||
*
|
||||
* 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
|
||||
@ -33,10 +33,10 @@ import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
/**
|
||||
* D-146, THE LINE THAT WAS MISSING. {@code PqRegistryHash.requireBindingsOrThrow} was delivered on
|
||||
* 2026-08-06 with its own tests, and nothing called it. Its own javadoc said so: "NOT WIRED YET ...
|
||||
* the call belongs beside AERE-PQC-REG-ARM-01 in FalconSealSupport, which is being edited by another
|
||||
* stream". This class measures the wire.
|
||||
* REGISTRY BINDING AT ARM TIME, THE LINE THAT WAS MISSING. {@code
|
||||
* PqRegistryHash.requireBindingsOrThrow} was delivered with its own tests, and nothing called it.
|
||||
* Its own javadoc said so: "NOT WIRED YET ... the call belongs beside AERE-PQC-REG-ARM-01 in
|
||||
* FalconSealSupport". This class measures the wire.
|
||||
*
|
||||
* <p>WHAT THE WIRE BUYS, stated as the thing that is actually true. Before it, an ARMED node loaded
|
||||
* a v1 registry without a word, and the registry decides who a Falcon seal is credited to. Measured
|
||||
@ -207,7 +207,7 @@ public class PqArmingGateTest {
|
||||
+ "trigger is ARMING and not the file")
|
||||
.doesNotThrowAnyException();
|
||||
assertThat(FalconSealSupport.instance().registrySize())
|
||||
.describedAs("and an unarmed node's registry is loaded exactly as it was before D-146")
|
||||
.describedAs("and an unarmed node's registry is loaded exactly as it was before this guard")
|
||||
.isEqualTo(N);
|
||||
}
|
||||
|
||||
@ -227,7 +227,8 @@ public class PqArmingGateTest {
|
||||
* An ARMED node with no registry file at all is deliberately NOT this guard's business, and this
|
||||
* test is what stops that from being a silent decision.
|
||||
*
|
||||
* <p>D-146 is mis-ATTRIBUTION, which needs rows; an empty registry credits nobody. The condition
|
||||
* <p>This guard is about mis-ATTRIBUTION, which needs rows; an empty registry credits nobody. The
|
||||
* condition
|
||||
* is owned by AERE-PQC-CFG-UNSAFE-08 when the threshold is positive, and MEASURED here: with a
|
||||
* threshold of zero, which is the warm-up regime the fleet is meant to arm INTO, the node starts.
|
||||
* An earlier revision of this guard refused here, and the cost was exactly that - the intended
|
||||
@ -242,7 +243,7 @@ public class PqArmingGateTest {
|
||||
System.setProperty("aere.falcon.testnetAllowSmallFleet", "true");
|
||||
|
||||
assertThatCode(FalconSealSupport::instance)
|
||||
.describedAs("K=0 over an empty registry is the warm-up regime, not a D-146 defect")
|
||||
.describedAs("K=0 over an empty registry is the warm-up regime, not a mis-attribution defect")
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@ -252,7 +253,7 @@ public class PqArmingGateTest {
|
||||
|
||||
/**
|
||||
* A row that carries a Falcon possession proof and no ECDSA claim proves that SOMEBODY holds the
|
||||
* key, and says nothing about which validator asked for it - which is the whole of D-146.
|
||||
* key, and says nothing about which validator asked for it - which is the whole of this guard.
|
||||
*
|
||||
* <p>MEASURED, and the assertion was CHANGED to match the measurement rather than the other way
|
||||
* round. The expectation written first was AERE-PQC-REG-ARM-02. What actually happens is a refusal
|
||||
@ -290,8 +291,8 @@ public class PqArmingGateTest {
|
||||
|
||||
/**
|
||||
* Arm through the CERTIFICATE ANCHOR only, leaving {@code aere.falcon.forkBlock} unset. The
|
||||
* threshold is 2, which {@code worstCaseKeyedSigners(4, 4)} = 3 guarantees, so the D-078 guard
|
||||
* next door stays silent and cannot be mistaken for this one.
|
||||
* threshold is 2, which {@code worstCaseKeyedSigners(4, 4)} = 3 guarantees, so the
|
||||
* threshold-reachability guard next door stays silent and cannot be mistaken for this one.
|
||||
*/
|
||||
private void armWithAnchorOnly() {
|
||||
System.setProperty(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, Long.toString(FORK));
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright contributors to Besu / AERE Network.
|
||||
* Copyright contributors to Besu / Aere Network.
|
||||
*
|
||||
* 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
|
||||
@ -39,7 +39,7 @@ import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
/**
|
||||
* D2 HARDENING (b-v2). The repair of the repair: the caller's MOTIVE decides, not the height.
|
||||
* ROTATION HARDENING (b-v2). The repair of the repair: the caller's MOTIVE decides, not the height.
|
||||
*
|
||||
* <p>WHAT THE FIRST SHAPE DID, MEASURED AND NOT ARGUED. On 2026-08-06 hardening (b) refused every
|
||||
* unbound height at or above the arming height, deciding from the block NUMBER alone. Run against
|
||||
@ -50,7 +50,7 @@ import org.junit.jupiter.api.io.TempDir;
|
||||
* height of 1000. A genuinely historical question, in the same process in the same second, hands
|
||||
* the guard exactly those numbers too. No arithmetic on the height separates them.
|
||||
*
|
||||
* <p>THE OPERATIONAL CONSEQUENCE, in the words of the D078 failure itself: {@code refusing to
|
||||
* <p>THE OPERATIONAL CONSEQUENCE, in the words of that failure itself: {@code refusing to
|
||||
* propose on top of block 1030 because this node holds 0 valid eligible Falcon seal(s)}. The first
|
||||
* shape turned a defect that is invisible on a running fleet and fatal only to a node syncing later
|
||||
* into one that stops block production on all seven, in the minute the anchor is armed.
|
||||
@ -64,7 +64,7 @@ import org.junit.jupiter.api.io.TempDir;
|
||||
* <p>THIS CLASS CANNOT GO GREEN BY ACCIDENT. Three of its tests fail if the own-head door is made
|
||||
* to refuse (which is the first shape restored), and three fail if the history door is made to
|
||||
* answer (which is the pre-2026-08-06 defect restored). The two plants are run in opposite
|
||||
* directions and both are recorded in the evidence directory.
|
||||
* directions, and each was measured before this class was allowed to count as evidence.
|
||||
*/
|
||||
public class PqCallerIntentTest {
|
||||
|
||||
@ -100,10 +100,10 @@ public class PqCallerIntentTest {
|
||||
|
||||
@BeforeEach
|
||||
public void setUp() throws Exception {
|
||||
// AERE D-146 (2026-08-06): a v2, PROOF-BOUND registry. It used to be v1 with addresses spelled
|
||||
// 0xA00+i, which no secp256k1 key can sign for, so this fixture described a fleet that could
|
||||
// never satisfy AERE-PQC-REG-ARM-02 once that guard was wired. The registry is bound at H, the
|
||||
// height this fixture arms the anchor from.
|
||||
// AERE REGISTRY BINDING (2026-08-06): a v2, PROOF-BOUND registry. It used to be v1, with
|
||||
// addresses spelled 0xA00+i, which no secp256k1 key can sign for, so this fixture described a
|
||||
// fleet that could never satisfy AERE-PQC-REG-ARM-02 once that guard was wired. The registry is
|
||||
// bound at H, the height this fixture arms the anchor from.
|
||||
final KeccakDigest kd = new KeccakDigest(256);
|
||||
final StringBuilder manifest = new StringBuilder();
|
||||
manifest
|
||||
@ -177,7 +177,7 @@ public class PqCallerIntentTest {
|
||||
.describedAs(
|
||||
"HISTORY door at 1030, armed from 1000, no schedule: a node judging somebody else's "
|
||||
+ "header cannot say which keys were in force there, so it REFUSES. Answering from "
|
||||
+ "the head registry here is D2/T2 verbatim")
|
||||
+ "the head registry here is the rotation defect verbatim")
|
||||
.isFalse();
|
||||
|
||||
assertThat(pqc.verifyAtOwnHead(OWN_HEAD, 0, MESSAGE, sealByIndexZero))
|
||||
@ -206,7 +206,7 @@ public class PqCallerIntentTest {
|
||||
.describedAs(
|
||||
"PqSealPersistenceTest restored 0 of 3 genuine seals under the first shape. A node "
|
||||
+ "that cannot re-read its own seal file after a restart is a node that cannot "
|
||||
+ "propose, and the file is the documented way out of the D-141 deadlock")
|
||||
+ "propose, and the file is the documented way out of that restart deadlock")
|
||||
.isTrue();
|
||||
assertThat(pqc.addressForIndexAtOwnHead(OWN_HEAD, 0))
|
||||
.describedAs("and the index must bind, or every stored seal is dropped as unknown")
|
||||
@ -282,13 +282,13 @@ public class PqCallerIntentTest {
|
||||
final FalconSealSupport pqc = FalconSealSupport.instance();
|
||||
final PqRegistryHash.Registry held = PqRegistryHash.loadAuto(genesisPath);
|
||||
final Map<Long, String> entries = new LinkedHashMap<>();
|
||||
// AERE D-146 (2026-08-06): hashFor, not hashV1. A schedule entry has to carry the canonical
|
||||
// hash OF THE REGISTRY IT NAMES, and this fixture's registry is now v2, which hashes under a
|
||||
// different domain tag. MEASURED: leaving hashV1 here made the entry name a registry nobody
|
||||
// holds, and the height-resolved lookups fell through to a refusal - a green test turning red
|
||||
// for a reason that had nothing to do with what it measures. This is the same breakage a real
|
||||
// genesis takes: any config.pqRegistryHash computed before the registry was rebuilt as v2
|
||||
// stops matching the moment it is rebuilt.
|
||||
// AERE REGISTRY BINDING (2026-08-06): hashFor, not hashV1. A schedule entry has to carry the
|
||||
// canonical hash OF THE REGISTRY IT NAMES, and this fixture's registry is now v2, which hashes
|
||||
// under a different domain tag. MEASURED: leaving hashV1 here made the entry name a registry
|
||||
// nobody holds, and the height-resolved lookups fell through to a refusal - a green test
|
||||
// turning red for a reason that had nothing to do with what it measures. This is the same
|
||||
// breakage a real genesis takes: any config.pqRegistryHash computed before the registry was
|
||||
// rebuilt as v2 stops matching the moment it is rebuilt.
|
||||
entries.put(H, PqRegistryHash.hashFor(held, CHAIN_ID));
|
||||
pqc.verifyRegistryBindingOrAbort(0L, CHAIN_ID, scheduleFromGenesis(entries));
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright contributors to Besu / AERE Network.
|
||||
* Copyright contributors to Besu / Aere Network.
|
||||
*
|
||||
* 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
|
||||
@ -38,11 +38,11 @@ import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
/**
|
||||
* D-140. THE FLEET-RESTART DEADLOCK, AND THE STATE MACHINE THE REPAIR MOVES.
|
||||
* THE FLEET-RESTART DEADLOCK, AND THE STATE MACHINE THE REPAIR MOVES.
|
||||
*
|
||||
* <p>MEASURED FIRST, ON A NETWORK, NOT ASSUMED. The full activation rehearsal on a seven-node test
|
||||
* network (repetitie-activare-2026-08-05) found that with the anchor armed at K>0 a SIMULTANEOUS
|
||||
* restart of every validator stops the chain for good. The node said it verbatim: "refusing to
|
||||
* <p>MEASURED FIRST, ON A NETWORK, NOT ASSUMED. A full activation rehearsal on a seven-node test
|
||||
* network found that with the anchor armed at K>0 a SIMULTANEOUS restart of every validator stops
|
||||
* the chain for good. The node said it verbatim: "refusing to
|
||||
* propose ... holds 0 valid eligible Falcon seal(s) ... threshold is 3", over "Attachment stays OFF
|
||||
* (fail-safe)".
|
||||
*
|
||||
@ -75,8 +75,8 @@ import org.junit.jupiter.api.io.TempDir;
|
||||
* no-op rather than a second registry load.
|
||||
* <li>{@link #aGenesisAnchoredNodeIsArmedImmediatelyAfterRestart()} - the rehearsal's own
|
||||
* stimulus replayed against THIS tree, and it does not fail the way the network did. Read its
|
||||
* javadoc: the rehearsal binary predates D-078, and the line it logged came from a condition
|
||||
* this tree no longer contains.
|
||||
* javadoc: the rehearsal binary predates the seal-attachment repair, and the line it logged
|
||||
* came from a condition this tree no longer contains.
|
||||
* </ol>
|
||||
*
|
||||
* <p>NOT MEASURED here, and named so it is not read as covered: that a real Besu process reads slot
|
||||
@ -84,15 +84,9 @@ import org.junit.jupiter.api.io.TempDir;
|
||||
* rehearsal network is the instrument for it), and that seven live nodes recover from a real
|
||||
* simultaneous restart with this binary. This class measures the decision the deadlock hinges on.
|
||||
*/
|
||||
// The D-140 label is our internal finding id. It names a fact about this
|
||||
// code, not anything outside it.
|
||||
public class PqFleetRestartArmingTest {
|
||||
|
||||
/**
|
||||
* Fleet size for THIS fixture. Not a statement about any live network: the 2026-08-05 decision
|
||||
* to stay at seven was reversed, and the set has been nine since 2026-08-12. Seven is kept here
|
||||
* because it is the size at which the margin arithmetic this class exercises is tightest.
|
||||
*/
|
||||
/** Fleet size: seven, the validator count this deployment runs. */
|
||||
private static final int N = 7;
|
||||
|
||||
/** Height at which the anchor contract is expected to be observable. */
|
||||
@ -116,21 +110,6 @@ public class PqFleetRestartArmingTest {
|
||||
|
||||
@BeforeEach
|
||||
public void setUp() throws Exception {
|
||||
// DATED 2026-08-20. This class never arms the certificate anchor, but FalconSealSupport's
|
||||
// constructor consults it (anchorArmedFrom() -> PqAnchorProducer.config(), a per-JVM cache):
|
||||
// a neighbouring test class that leaves an ARMED anchor config cached in this JVM turns every
|
||||
// proof-less fixture below into an AERE-PQC-REG-ARM-02 refusal. Measured on the production
|
||||
// tree that day: this class ALONE 5/5 green, inside the full suite the same 5 red, identical
|
||||
// sources -- the 2026-08-11 order-luck lesson verbatim ("clearing the properties does not
|
||||
// clear the caches"). The defence belongs to the consumer: start from an unarmed anchor,
|
||||
// cache and properties both.
|
||||
for (final String p : System.getProperties().stringPropertyNames()) {
|
||||
if (p.startsWith("aere.pq.")) {
|
||||
System.clearProperty(p);
|
||||
}
|
||||
}
|
||||
org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer.useConfigForTesting(
|
||||
null);
|
||||
resetFalconSingleton();
|
||||
|
||||
final SecureRandom rnd = SecureRandomProvider.createSecureRandom();
|
||||
@ -365,9 +344,9 @@ public class PqFleetRestartArmingTest {
|
||||
* The seven-node rehearsal ran a GENESIS-anchored registry, and the line it logged after the
|
||||
* simultaneous restart was the COVERAGE one: "no validator set has been observed yet, so registry
|
||||
* COVERAGE cannot be proven. Attachment stays OFF (fail-safe)". That condition does not exist in
|
||||
* this tree: {@code grep} for it returns nothing, because D-078 (2026-08-02) removed the fleet
|
||||
* question from the per-commit gate. The rehearsal binary was built from the 2026-08-01 tree,
|
||||
* which still had it.
|
||||
* this tree: {@code grep} for it returns nothing, because the seal-attachment repair took the
|
||||
* fleet-wide question out of the per-commit gate. The rehearsal binary was built before that
|
||||
* repair landed, and still had it.
|
||||
*
|
||||
* <p>So this test states what is true HERE: a genesis-anchored node, freshly constructed, with no
|
||||
* validator set observed and no block imported, IS armed. The rehearsal's measured deadlock is
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright contributors to Besu / AERE Network.
|
||||
* Copyright contributors to Besu / Aere Network.
|
||||
*
|
||||
* 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
|
||||
@ -30,11 +30,11 @@ import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
/**
|
||||
* D-079. THE MEASUREMENT THAT DID NOT EXIST.
|
||||
* FORK-HEIGHT ARMING. THE MEASUREMENT THAT DID NOT EXIST.
|
||||
*
|
||||
* <p>The registry entry reads: "a malformed forkBlock falls OPEN, with only a log line, and arming
|
||||
* it at or before the anchor observation height passes undetected", and it carried {@code verifica:
|
||||
* NICIUNA} since 18 July. This file is the command that can fail.
|
||||
* <p>The concern, as it was written down: a malformed forkBlock falls OPEN, with only a log line,
|
||||
* and arming it at or before the anchor observation height passes undetected. It stood as an
|
||||
* assertion with no command behind it for months. This file is the command that can fail.
|
||||
*
|
||||
* <p>Both halves of the finding are about the SAME shape of defect, the one the Holesky Pectra
|
||||
* incident of February 2025 made expensive for everybody: a fork-activation parameter that is wrong
|
||||
@ -75,8 +75,6 @@ import org.junit.jupiter.api.io.TempDir;
|
||||
* the guards that already abort, and nothing in this tree catches {@code
|
||||
* FalconSealSupport.ActivationConfigException}.
|
||||
*/
|
||||
// The D-079 label is our internal finding id. It names a fact about this
|
||||
// code, not anything outside it.
|
||||
public class PqForkArmingTest {
|
||||
|
||||
/** Fleet size; nine, because the blocking guard refuses to arm below nine. */
|
||||
@ -94,8 +92,8 @@ public class PqForkArmingTest {
|
||||
private static final String ANCHOR_ADDRESS = "0x0000000000000000000000000000000000000fa1";
|
||||
|
||||
/**
|
||||
* AERE D-146: the chain this fixture's registries are BOUND to. Every proof commits to it, so it
|
||||
* has to be stated rather than defaulted.
|
||||
* REGISTRY BINDING: the chain this fixture's registries are BOUND to. Every proof commits to it,
|
||||
* so it has to be stated rather than defaulted.
|
||||
*/
|
||||
private static final long CHAIN_ID = 2_800L;
|
||||
|
||||
@ -106,7 +104,7 @@ public class PqForkArmingTest {
|
||||
|
||||
@BeforeEach
|
||||
public void setUp() throws Exception {
|
||||
// AERE D-146 (2026-08-06): both registries below are v2 and PROOF-BOUND, bound at FORK, the
|
||||
// REGISTRY BINDING: both registries below are v2 and PROOF-BOUND, bound at FORK, the
|
||||
// height this fixture arms from. They used to carry addresses spelled 0xB00+i, which no
|
||||
// secp256k1 key can sign for, so this whole fixture became unstartable the moment
|
||||
// AERE-PQC-REG-ARM-02 was wired into the constructor.
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright contributors to Besu / AERE Network.
|
||||
* Copyright contributors to Besu / Aere Network.
|
||||
*
|
||||
* 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
|
||||
@ -33,7 +33,8 @@ import org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
/**
|
||||
* D-078, THE HALF THAT WAS STILL OPEN: is the threshold K one the fleet can be GUARANTEED to meet?
|
||||
* THRESHOLD REACHABILITY, THE HALF THAT WAS STILL OPEN: is the threshold K one the fleet can be
|
||||
* GUARANTEED to meet?
|
||||
*
|
||||
* <p>The 2026-08-02 repair closed the mechanism that stopped the chain on one add-validator vote: it
|
||||
* took the fleet-wide coverage question out of the per-commit attachment gate and made coverage a
|
||||
@ -66,8 +67,8 @@ import org.junit.jupiter.api.io.TempDir;
|
||||
* is not re-anchored on the way there it produces a fleet that arms a threshold no proposer is
|
||||
* guaranteed to meet. Before this guard a node in that state started, joined, armed, and the failure
|
||||
* appeared later as a proposer that could not propose. That is the most expensive shape a
|
||||
* configuration error can take, and it is the same shape the A8 repair already refused to allow for
|
||||
* a non-address-bound manifest.
|
||||
* configuration error can take, and it is the same shape the address-binding guard already refused
|
||||
* to allow for a non-address-bound manifest.
|
||||
*
|
||||
* <p>WHY AT CONFIG TIME AND NOWHERE ELSE. The lesson is borrowed, not invented: CometBFT applies a
|
||||
* validator-set change only at H+2 and Ethereum's light-client protocol carries {@code
|
||||
@ -76,14 +77,13 @@ import org.junit.jupiter.api.io.TempDir;
|
||||
* mechanism, because at seven nodes under one operator there is no committee to sample. We can copy
|
||||
* the discipline: DECLARE the fleet size, compare it against the threshold at config time, and
|
||||
* refuse to cross the boundary if the comparison fails. The same reasoning already produced
|
||||
* AERE-PQC-CFG-UNSAFE-04 and, for the fork height, AERE-PQC-CFG-UNSAFE-06/07 in D-079.
|
||||
* AERE-PQC-CFG-UNSAFE-04 and, for the fork height, AERE-PQC-CFG-UNSAFE-06/07 in the fork-arming
|
||||
* configuration guard.
|
||||
*
|
||||
* <p>NOT MEASURED here, and named so it is not read as covered: what a LIVE fleet does in the rounds
|
||||
* between the vote landing and the first proposer failing. That needs a network. This class measures
|
||||
* the decision, which is the thing a node can be stopped from taking.
|
||||
*/
|
||||
// The D-078 label is our internal finding id. It names a fact about this
|
||||
// code, not anything outside it.
|
||||
public class PqForkThresholdReachabilityTest {
|
||||
|
||||
/** Anchor activation height H. */
|
||||
@ -96,8 +96,8 @@ public class PqForkThresholdReachabilityTest {
|
||||
private static final int K = 5;
|
||||
|
||||
/**
|
||||
* AERE D-146: the chain the registries this fixture writes are BOUND to. It is the same value
|
||||
* {@link #armAnchor} states in {@code aere.pq.chainId}: a registry bound to one chain and an
|
||||
* REGISTRY BINDING: the chain the registries this fixture writes are BOUND to. It is the same
|
||||
* value {@link #armAnchor} states in {@code aere.pq.chainId}: a registry bound to one chain and an
|
||||
* anchor armed on another is a configuration this fixture must never accidentally describe.
|
||||
*/
|
||||
private static final long CHAIN_ID = 2_800L;
|
||||
@ -198,8 +198,8 @@ public class PqForkThresholdReachabilityTest {
|
||||
|
||||
@Test
|
||||
public void withNoAnchorConfiguredTheGuardIsInert() throws Exception {
|
||||
// aere.pq.anchorBlock is UNSET on the live chain, so K does not exist and there is nothing to
|
||||
// compare. A guard that could stop a node in that state would be a new way to lose the fleet,
|
||||
// With aere.pq.anchorBlock unset there is no anchor, so K does not exist and there is nothing
|
||||
// to compare. A guard that could stop a node in that state would be a new way to lose the fleet,
|
||||
// which is a strictly worse defect than the one it repairs.
|
||||
writeAnchoredRegistry(7);
|
||||
System.setProperty("aere.falcon.validatorCount", "9");
|
||||
@ -227,8 +227,9 @@ public class PqForkThresholdReachabilityTest {
|
||||
@Test
|
||||
public void aPositiveThresholdWithNoAnchoredKeysMustRefuseToStart() throws Exception {
|
||||
// No manifest anywhere and K=5: guaranteed is 0, so every block at or above H would be rejected
|
||||
// for want of a certificate nobody can produce. Distinct from the A8 refusal, which only fires
|
||||
// when aere.falcon.forkBlock is set; the anchor path has its own arming height.
|
||||
// for want of a certificate nobody can produce. Distinct from the genesis-binding refusal,
|
||||
// which only fires when aere.falcon.forkBlock is set; the anchor path has its own arming
|
||||
// height.
|
||||
System.setProperty("aere.falcon.validatorCount", "7");
|
||||
armAnchor(K);
|
||||
|
||||
@ -301,7 +302,7 @@ public class PqForkThresholdReachabilityTest {
|
||||
* genesis is rather than by a flag.
|
||||
*/
|
||||
private void writeAnchoredRegistry(final int count) throws Exception {
|
||||
// AERE D-146 (2026-08-06): v2, proof-bound, bound at H, the height armAnchor() arms from. The
|
||||
// REGISTRY BINDING: v2, proof-bound, bound at H, the height armAnchor() arms from. The
|
||||
// rows come from PqV2Fixture because a v2 claim must be signed by the validator whose address
|
||||
// is on the row, and the 0xA00+i addresses this used to spell have no key behind them.
|
||||
final KeccakDigest kd = new KeccakDigest(256);
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright contributors to Besu / AERE Network.
|
||||
* Copyright contributors to Besu / Aere Network.
|
||||
*
|
||||
* 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
|
||||
@ -52,12 +52,12 @@ import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.quality.Strictness;
|
||||
|
||||
/**
|
||||
* D-078. THE MEASUREMENT THAT DID NOT EXIST.
|
||||
* VALIDATOR-SET CHANGE UNDER AN ARMED ANCHOR. THE MEASUREMENT THAT DID NOT EXIST.
|
||||
*
|
||||
* <p>The registry entry reads: "if PQC were armed, an ordinary add-validator vote would stop the
|
||||
* chain: the Falcon blocking quorum follows the dynamic set and cannot be reached inside the vote
|
||||
* window", and it carried {@code verifica: NICIUNA} because "the direct measurement would require
|
||||
* ARMING PQC on a chain, which is exactly the thing that stops the chain".
|
||||
* <p>The concern, as it was written down, was this: if PQC were armed, an ordinary add-validator
|
||||
* vote would stop the chain, because the Falcon blocking quorum follows the dynamic set and cannot
|
||||
* be reached inside the vote window. It was carried as UNMEASURED, on the argument that the direct
|
||||
* measurement would require ARMING PQC on a chain, which is exactly the thing that stops the chain.
|
||||
*
|
||||
* <p>That is true of a whole chain. It is NOT true of the decision that stops it. Every step from
|
||||
* "the validator set changed" to "no block can be proposed" is taken by three objects in this
|
||||
@ -65,16 +65,16 @@ import org.mockito.quality.Strictness;
|
||||
* decides whether this node emits a Falcon seal at all, {@link PqSealCache} holds what was heard,
|
||||
* and {@link PqAnchorProducer#apply} decides whether this node may propose. This class drives those
|
||||
* three with a REAL address-bound genesis-anchored registry and REAL Falcon-512 keys, and asks the
|
||||
* question the registry says cannot be asked.
|
||||
* question that was held to be unaskable.
|
||||
*
|
||||
* <p>WHAT EACH TEST MEASURES, and why each of them can fail:
|
||||
*
|
||||
* <ol>
|
||||
* <li>{@link #baselineTheGateIsArmedWhileTheRegistryCoversTheSet()} - the negative control for
|
||||
* every other test here. If the gate were simply always off, or the registry never loaded,
|
||||
* the three tests below would "pass" for a reason that has nothing to do with D-078. This one
|
||||
* fails if the fixture is not genuinely armed.
|
||||
* <li>{@link #addingOneValidatorMustNotTurnSealAttachmentOff()} - D-078 itself, on the exact
|
||||
* the three tests below would "pass" for a reason that has nothing to do with the question
|
||||
* under test. This one fails if the fixture is not genuinely armed.
|
||||
* <li>{@link #addingOneValidatorMustNotTurnSealAttachmentOff()} - the concern itself, on the exact
|
||||
* stimulus in the title: one more validator in the set, with no Falcon key.
|
||||
* <li>{@link #aNodeStartedAboveTheAnchorHeightMustStillAttach()} - the SAME halt through a much
|
||||
* more ordinary door than a vote: a restart. Above the anchor height the only caller of
|
||||
@ -90,8 +90,6 @@ import org.mockito.quality.Strictness;
|
||||
* live fleet takes to stop once every proposer refuses, and what a syncing node does meanwhile.
|
||||
* Those need a network, and the network run is separate evidence.
|
||||
*/
|
||||
// The D-078 label is our internal finding id. It names a fact about this
|
||||
// code, not anything outside it.
|
||||
public class PqForkValidatorSetChangeTest {
|
||||
|
||||
/** Anchor activation height H used throughout. */
|
||||
@ -123,7 +121,7 @@ public class PqForkValidatorSetChangeTest {
|
||||
// equals the hash stored in the genesis alloc. The digest is accumulated here in lockstep with
|
||||
// the manifest text, so the fixture is anchored the same way a real genesis is.
|
||||
//
|
||||
// AERE D-146 (2026-08-06): v2, proof-bound, bound at H. The addresses come from PqV2Fixture and
|
||||
// REGISTRY BINDING: v2, proof-bound, bound at H. The addresses come from PqV2Fixture and
|
||||
// are DERIVED from real secp256k1 keys, because a claim has to be signed by the validator whose
|
||||
// address is on the row and no key produces the 0xA00+i addresses this used to spell.
|
||||
final KeccakDigest kd = new KeccakDigest(256);
|
||||
@ -215,7 +213,7 @@ public class PqForkValidatorSetChangeTest {
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------------------
|
||||
// 2. D-078 on its own stimulus: one validator added.
|
||||
// 2. The concern on its own stimulus: one validator added.
|
||||
// -----------------------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
@ -233,10 +231,9 @@ public class PqForkValidatorSetChangeTest {
|
||||
|
||||
assertThat(pqc.attachmentArmed(H + 3L))
|
||||
.describedAs(
|
||||
"D-078: one ordinary add-validator vote must not switch Falcon seal ATTACHMENT off. "
|
||||
+ "It is a fleet-wide fact, so it turns off on EVERY node at the same height; with "
|
||||
+ "no node attaching, no proposer can gather K=%d seals and the chain stops with no "
|
||||
+ "way to carry the re-anchoring transaction that would repair it.",
|
||||
"one ordinary add-validator vote must not switch Falcon seal ATTACHMENT off. It is a "
|
||||
+ "fleet-wide fact, so it turns off on EVERY node at the same height, and with no "
|
||||
+ "node attaching no proposer can gather the K=%d seals an anchored header needs.",
|
||||
K)
|
||||
.isTrue();
|
||||
assertThat(pqc.sign(H + 3L, message(H + 2L)))
|
||||
@ -353,7 +350,7 @@ public class PqForkValidatorSetChangeTest {
|
||||
* Every other test here asserts that the gate says YES. Replace {@code attachmentArmed} with
|
||||
* {@code return true} and all of them still pass, which would make this file a proof that cannot
|
||||
* fail. These four assertions are what makes that substitution impossible: each names a condition
|
||||
* the D-078 repair deliberately did NOT touch.
|
||||
* the attachment repair deliberately did NOT touch.
|
||||
*
|
||||
* @throws Exception if the fixture cannot be rebuilt
|
||||
*/
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright contributors to Besu / AERE Network.
|
||||
* Copyright contributors to Besu / Aere Network.
|
||||
*
|
||||
* 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
|
||||
@ -51,13 +51,13 @@ import org.mockito.quality.Strictness;
|
||||
/**
|
||||
* THE COMPATIBILITY PROPERTY, which is the one that decides whether any of this can be shipped.
|
||||
*
|
||||
* <p>The three anchor patches plus the D-146 arming gate are meant to travel onto the seven live
|
||||
* boxes BEFORE the activation height, so that the fleet is already running the binary when the
|
||||
* height arrives and activation is a restart-free event. That plan is only sound if a node holding
|
||||
* this binary and NO {@code aere.pq.*} configuration is indistinguishable from one holding the
|
||||
* binary it replaces: it must start, it must produce blocks, and it must not say a word about an
|
||||
* anchor that is not armed. If that property is lost, the whole package is unusable regardless of
|
||||
* how correct the anchor logic is, because it could not be staged.
|
||||
* <p>The three anchor patches plus the registry-binding arming gate are meant to travel onto the
|
||||
* seven live boxes BEFORE the activation height, so that the fleet is already running the binary
|
||||
* when the height arrives and activation is a restart-free event. That plan is only sound if a node
|
||||
* holding this binary and NO {@code aere.pq.*} configuration is indistinguishable from one holding
|
||||
* the binary it replaces: it must start, it must produce blocks, and it must not say a word about
|
||||
* an anchor that is not armed. If that property is lost, the whole package is unusable regardless
|
||||
* of how correct the anchor logic is, because it could not be staged.
|
||||
*
|
||||
* <p>WHY THE SILENCE IS MEASURED AND NOT ASSUMED. "It returns early, so it cannot log" is a reading
|
||||
* of the code, not a measurement, and the integrated tree has four patches whose log sites nobody
|
||||
|
||||
@ -38,7 +38,7 @@ import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
/**
|
||||
* D-228 (2026-08-15): the PARENT-HEIGHT question at the first anchor.
|
||||
* SCHEDULE BOUNDARY (2026-08-15): the PARENT-HEIGHT question at the first anchor.
|
||||
*
|
||||
* <p>WHAT WAS MEASURED, on a public node synced from genesis on the live chain. {@code
|
||||
* PqAnchorSealsRule} judges the certificate carried by the block at the first anchor height H by
|
||||
@ -111,7 +111,7 @@ public class PqParentHeightAlignmentTest {
|
||||
genesisPath = tmp.resolve("genesis-d228.json");
|
||||
Files.writeString(genesisPath, manifest.toString());
|
||||
// DELIBERATELY NOT setting aere.falcon.genesis: the head registry stays EMPTY, which is the
|
||||
// public-node shape D-228 was measured on.
|
||||
// public-node shape the defect was measured on.
|
||||
|
||||
// A genuine Falcon-512 signature by index 0 over MESSAGE.
|
||||
final FalconSigner signer = new FalconSigner();
|
||||
@ -168,7 +168,7 @@ public class PqParentHeightAlignmentTest {
|
||||
final FalconSealSupport pqc = FalconSealSupport.instance();
|
||||
assertThat(pqc.addressForIndexAtHistoric(H - 1L, 0))
|
||||
.describedAs(
|
||||
"D-228: PqAnchorSealsRule asks at the PARENT height H-1 about the certificate carried "
|
||||
"SCHEDULE BOUNDARY: PqAnchorSealsRule asks at the PARENT height H-1 about the certificate carried "
|
||||
+ "by the block at H. The registry governing that certificate is the one bound at "
|
||||
+ "H, and it is VERIFIED; refusing here parks a syncing node at H-1 forever")
|
||||
.isEqualTo(validators.get(0));
|
||||
|
||||
@ -47,8 +47,8 @@ import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
/**
|
||||
* AERE D-146. The registry says validator i has Falcon key k. Nothing said validator i ever agreed
|
||||
* to that, or that anybody holds k's secret.
|
||||
* AERE REGISTRY BINDING. The registry says validator i has Falcon key k. Nothing said validator i
|
||||
* ever agreed to that, or that anybody holds k's secret.
|
||||
*
|
||||
* <p>WHAT WAS MEASURED BEFORE THIS TEST EXISTED, on the real verification path, with the startup
|
||||
* gate reporting MATCH and the header ACCEPTED every time:
|
||||
@ -68,7 +68,7 @@ import org.junit.jupiter.api.io.TempDir;
|
||||
*
|
||||
* <p>KEYS. Every Falcon and ECDSA key here is generated in memory, used inside one test method, and
|
||||
* never written anywhere but a JUnit temporary directory. Nothing in this file touches the key
|
||||
* ceremony, the vault, or the three locks that stand in front of real key generation.
|
||||
* ceremony or the controls that stand in front of real key generation.
|
||||
*/
|
||||
class PqRegistryBindingTest {
|
||||
|
||||
@ -172,7 +172,7 @@ class PqRegistryBindingTest {
|
||||
// ceremony: the registry writer, who has every FALCON secret. It signs a perfectly valid
|
||||
// possession proof for key 0 sitting under validator 1's address. Only the ECDSA claim, which
|
||||
// needs validator 1's consensus key, stops it - and that is the whole argument for why a Falcon
|
||||
// proof-of-possession alone does not repair D-146.
|
||||
// proof-of-possession alone does not repair the attribution gap.
|
||||
final List<Row> rows = rows();
|
||||
rows.get(0).address = holders.get(1).address();
|
||||
rows.get(1).address = holders.get(3).address(); // keep addresses distinct
|
||||
@ -490,8 +490,8 @@ class PqRegistryBindingTest {
|
||||
* Re-sign every row the way THE REGISTRY WRITER would at a key ceremony: it holds every FALCON
|
||||
* secret, so it can always produce a valid possession proof for whatever row it just wrote. The
|
||||
* ECDSA claim it can produce is the one belonging to the holder of the key on that row, never the
|
||||
* one belonging to the address it filed the key under. That gap is the whole of D-146 and it is
|
||||
* why the Falcon half alone repairs nothing.
|
||||
* one belonging to the address it filed the key under. That gap is the whole of the attribution
|
||||
* problem this class exists for, and it is why the Falcon half alone repairs nothing.
|
||||
*/
|
||||
private static void resignAsRegistryWriter(final List<Row> rows) {
|
||||
for (int i = 0; i < rows.size(); i++) {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright contributors to Besu / AERE Network.
|
||||
* Copyright contributors to Besu / Aere Network.
|
||||
*
|
||||
* 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
|
||||
@ -38,16 +38,17 @@ import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
/**
|
||||
* D2, the adversarial review of 2026-08-02, at the layer that actually answers the question.
|
||||
* KEY ROTATION AND REBINDING, the adversarial review of 2026-08-02, at the layer that actually
|
||||
* answers the question.
|
||||
*
|
||||
* <p>WHAT THE DOSSIER MEASURED. {@code PqSignerRegistry} had {@code addressForIndex(int)} and {@code
|
||||
* <p>WHAT THE REVIEW MEASURED. {@code PqSignerRegistry} had {@code addressForIndex(int)} and {@code
|
||||
* verify(int, Bytes, Bytes)} with no height, and {@code FalconSealSupport} held ONE registry loaded
|
||||
* at start-up. So a header that passed both anchor rules was REJECTED the moment index 0's Falcon
|
||||
* key was rotated - same header, same parent, same validator set.
|
||||
*
|
||||
* <p>WHAT WAS REPAIRED BEFORE THIS FILE, AND WHAT WAS NOT. Commit f3ebe90c (D-081) gave the
|
||||
* validation path {@code addressForIndexAt} / {@code verifyAt} and a height-indexed schedule. The
|
||||
* measurement of 2026-08-05 found the repair INERT, for a reason that is one line long: with no
|
||||
* <p>WHAT WAS REPAIRED BEFORE THIS FILE, AND WHAT WAS NOT. The height-indexed registry change gave
|
||||
* the validation path {@code addressForIndexAt} / {@code verifyAt} and a height-indexed schedule.
|
||||
* The measurement of 2026-08-05 found the repair INERT, for a reason that is one line long: with no
|
||||
* {@code config.pqRegistryHash} in genesis - and there is none in any genesis this fleet runs -
|
||||
* {@code keyAt} fell back to the registry in force AT THE HEAD, at every height. Height-aware
|
||||
* signatures, head-registry answers. T2 stood exactly as measured.
|
||||
@ -94,8 +95,8 @@ public class PqRegistryHeightRefusalTest {
|
||||
|
||||
@BeforeEach
|
||||
public void setUp() throws Exception {
|
||||
// AERE D-146 (2026-08-06): v2, proof-bound, bound at H. See PqV2Fixture for why the addresses
|
||||
// are derived from real secp256k1 keys and can no longer be spelled 0xA00+i.
|
||||
// AERE REGISTRY BINDING (2026-08-06): v2, proof-bound, bound at H. See PqV2Fixture for why the
|
||||
// addresses are derived from real secp256k1 keys and can no longer be spelled 0xA00+i.
|
||||
final KeccakDigest kd = new KeccakDigest(256);
|
||||
final StringBuilder manifest = new StringBuilder();
|
||||
manifest
|
||||
@ -168,10 +169,11 @@ public class PqRegistryHeightRefusalTest {
|
||||
|
||||
// No schedule was ever loaded: verifyRegistryBindingOrAbort has not run, which is the state of
|
||||
// every node on chain 2800 today, because config.pqRegistryHash is in no genesis this fleet
|
||||
// runs (measured 2026-08-05, grep over deploy/ and monitoring/ returns nothing).
|
||||
// runs (measured 2026-08-05: a search across every deployment and monitoring configuration we
|
||||
// hold returns nothing).
|
||||
assertThat(pqc.verifyAtHistoric(H, 0, MESSAGE, sealByIndexZero))
|
||||
.describedAs(
|
||||
"D2/T2: at the arming height itself, a node with no height-to-registry binding must "
|
||||
"T2: at the arming height itself, a node with no height-to-registry binding must "
|
||||
+ "REFUSE. Before 2026-08-06 it answered from the registry in force at the HEAD, "
|
||||
+ "so one key rotation made every block above H unverifiable while the node "
|
||||
+ "reported success")
|
||||
@ -235,9 +237,9 @@ public class PqRegistryHeightRefusalTest {
|
||||
// The first scheduled entry sits EXACTLY at the arming height, which is the rule the epoch-list
|
||||
// design states: below H requiredHashAt is empty and the fallback is unreachable by anything
|
||||
// that decides a header.
|
||||
// AERE D-146 (2026-08-06): the hash above is hashFor, not hashV1, because this fixture's
|
||||
// registry is now v2 and hashes under a different domain tag. A schedule entry that names the
|
||||
// v1 number names a registry this node does not hold.
|
||||
// AERE REGISTRY BINDING (2026-08-06): the hash above is hashFor, not hashV1, because this
|
||||
// fixture's registry is now v2 and hashes under a different domain tag. A schedule entry that
|
||||
// names the v1 number names a registry this node does not hold.
|
||||
final PqRegistryHash.Schedule schedule = scheduleFromGenesis(Map.of(H, hash));
|
||||
pqc.verifyRegistryBindingOrAbort(0L, CHAIN_ID, schedule);
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright contributors to Besu / AERE Network.
|
||||
* Copyright contributors to Besu / Aere Network.
|
||||
*
|
||||
* 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
|
||||
@ -30,12 +30,13 @@ import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
/**
|
||||
* D-081: the Falcon signer registry has no usable rotation and no usable revocation.
|
||||
* SIGNER-REGISTRY ROTATION AND REVOCATION: the Falcon signer registry has no usable rotation and no
|
||||
* usable revocation.
|
||||
*
|
||||
* <p>WHAT IS MEASURED HERE, and why it is measured against the real classes rather than described.
|
||||
* {@code config.pqRegistryHash} is a SCHEDULE of {block, hash} entries, and the A8 dossier states
|
||||
* that "a later entry expresses a key rotation". This file asks whether that sentence survives
|
||||
* contact with the code that enforces it.
|
||||
* {@code config.pqRegistryHash} is a SCHEDULE of {block, hash} entries, and the format is documented
|
||||
* as one where "a later entry expresses a key rotation". This file asks whether that sentence
|
||||
* survives contact with the code that enforces it.
|
||||
*
|
||||
* <p>The enforcement side is {@link PqRegistryHash#matchesAt} and, on the block path, {@code
|
||||
* FalconSealSupport.registryBindingSatisfiedAt(height)}, which calls it. Both take exactly ONE
|
||||
@ -44,12 +45,10 @@ import org.junit.jupiter.api.io.TempDir;
|
||||
* H2 there are two intervals with two different required hashes, and one file can satisfy at most
|
||||
* one of them.
|
||||
*
|
||||
* <p>The consequence is not cosmetic and it is not confined to the rotation moment. {@code
|
||||
* PqRegistryBindingRule} is a DETACHED rule, so it runs on the header-download path, and {@code
|
||||
* PqAnchorSyncModeGuard} refuses to start an armed node in anything but FULL sync. A node acquiring
|
||||
* history therefore validates every height, including the interval before the rotation. Holding the
|
||||
* post-rotation registry it is refused there; holding the pre-rotation registry it is refused at the
|
||||
* head. There is no third choice. ONE rotation makes the chain permanently unjoinable.
|
||||
* <p>The consequence is not cosmetic and it is not confined to the rotation moment. The binding is
|
||||
* enforced while history is being acquired, not only at the head, so the whole range of heights has
|
||||
* to be satisfiable at once and not merely the current interval. That is the constraint the two
|
||||
* measurements below are written against.
|
||||
*
|
||||
* <p>This is the lesson Cosmos ADR-016 writes down explicitly: a rotation scheme has to keep the
|
||||
* MAPPING FROM HEIGHT TO KEY SET, not only the current key set, or blocks signed under the old set
|
||||
@ -63,8 +62,6 @@ import org.junit.jupiter.api.io.TempDir;
|
||||
* that the schedule really does express rotation and really does refuse a malformed one, so a
|
||||
* failure of the two measurements cannot be blamed on the fixture.
|
||||
*/
|
||||
// The D-081 label is our internal finding id. It names a fact about this
|
||||
// code, not anything outside it.
|
||||
public class PqRegistryRotationTest {
|
||||
|
||||
private static final long CHAIN_ID = 2800L;
|
||||
@ -175,7 +172,7 @@ public class PqRegistryRotationTest {
|
||||
} catch (final IOException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
return PqRegistryHash.parseSchedule(node, "D-081 fixture");
|
||||
return PqRegistryHash.parseSchedule(node, "rotation fixture");
|
||||
}
|
||||
|
||||
/** Every height at which the binding is enforced and could differ across the rotation. */
|
||||
@ -205,7 +202,7 @@ public class PqRegistryRotationTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* D-081 repair: the node holds the WHOLE scheduled history and resolves by height. This
|
||||
* THE REPAIR: the node holds the WHOLE scheduled history and resolves by height. This
|
||||
* configuration did not exist before the repair, which is why the assertion below could not be
|
||||
* satisfied by any node at all.
|
||||
*/
|
||||
@ -266,7 +263,7 @@ public class PqRegistryRotationTest {
|
||||
final String json =
|
||||
"[{\"block\":" + H2 + ",\"hash\":\"0x" + h + "\"},{\"block\":" + H1 + ",\"hash\":\"0x" + h + "\"}]";
|
||||
final JsonNode node = new ObjectMapper().readTree(json);
|
||||
assertThatThrownBy(() -> PqRegistryHash.parseSchedule(node, "D-081 fixture"))
|
||||
assertThatThrownBy(() -> PqRegistryHash.parseSchedule(node, "rotation fixture"))
|
||||
.isInstanceOf(PqRegistryHash.RegistryConfigException.class)
|
||||
.hasMessageContaining("STRICTLY INCREASING");
|
||||
}
|
||||
@ -306,16 +303,10 @@ public class PqRegistryRotationTest {
|
||||
|
||||
assertThat(complete)
|
||||
.withFailMessage(
|
||||
"ROTATION IS NOT USABLE: one scheduled rotation at height %d leaves NO node configuration "
|
||||
+ "that "
|
||||
+ "satisfies the registry binding at every enforced height. %s. A node that cannot "
|
||||
+ "satisfy the binding at a height cannot import a header at that height "
|
||||
+ "(PqRegistryBindingRule is DETACHED, so it runs on the header-download path), and "
|
||||
+ "PqAnchorSyncModeGuard forces FULL sync when the anchor is armed, so every node "
|
||||
+ "acquiring history must pass through the pre-rotation interval AND reach the head. "
|
||||
+ "Using the rotation mechanism once therefore makes the chain permanently "
|
||||
+ "unjoinable. A rotation scheme must keep the whole HEIGHT-TO-KEY-SET mapping "
|
||||
+ "loadable, not only the current entry.",
|
||||
"ROTATION UNUSABLE: one scheduled rotation at height %d leaves NO node configuration "
|
||||
+ "that satisfies the registry binding at every enforced height. %s. A rotation "
|
||||
+ "scheme must keep the whole HEIGHT-TO-KEY-SET mapping loadable, not only the "
|
||||
+ "current entry.",
|
||||
H2,
|
||||
String.join("; ", report))
|
||||
.isNotNull();
|
||||
@ -354,10 +345,9 @@ public class PqRegistryRotationTest {
|
||||
|
||||
assertThat(complete)
|
||||
.withFailMessage(
|
||||
"REVOCATION IS NOT USABLE: revoking one signer at height %d leaves NO node "
|
||||
"REVOCATION UNUSABLE: revoking one compromised signer at height %d leaves NO node "
|
||||
+ "configuration that satisfies the binding at every enforced height. %s. The "
|
||||
+ "revocation is expressible and is not usable: performing it costs the ability to "
|
||||
+ "acquire the chain.",
|
||||
+ "revocation is expressible and is not usable.",
|
||||
H2,
|
||||
String.join("; ", report))
|
||||
.isNotNull();
|
||||
@ -429,7 +419,8 @@ public class PqRegistryRotationTest {
|
||||
// Registry objects the test built itself. No operator can do that. What an operator can do is
|
||||
// write a comma-separated list of FILE PATHS into aere.falcon.registry.history, and the node
|
||||
// turns that string into the same set through parseRegistryPaths + loadAuto
|
||||
// (FalconSealSupport.verifyRegistryBindingOrAbort, the D-081 block). If that route were broken
|
||||
// (FalconSealSupport.verifyRegistryBindingOrAbort, the registry-binding block). If that route
|
||||
// were broken
|
||||
// the other six would still be green and the capability would still not be usable, which is the
|
||||
// exact shape of "a green result in a reduced environment is true and worthless".
|
||||
//
|
||||
@ -462,10 +453,9 @@ public class PqRegistryRotationTest {
|
||||
}
|
||||
assertThat(refused)
|
||||
.withFailMessage(
|
||||
"ROTATION IS NOT USABLE on the route an operator can actually take: the history list %s "
|
||||
+ "parses "
|
||||
+ "and loads, and the resulting set is still refused at %s. The library can express "
|
||||
+ "the whole height-to-key-set mapping but the configuration string cannot reach "
|
||||
"ROTATION UNUSABLE on the route an operator can actually take: the history list %s "
|
||||
+ "parses and loads, and the resulting set is still refused at %s. The library can "
|
||||
+ "express the whole height-to-key-set mapping but the configuration string cannot reach "
|
||||
+ "it, so the rotation remains expressible and not usable.",
|
||||
configured, refused)
|
||||
.isEmpty();
|
||||
|
||||
@ -1,127 +0,0 @@
|
||||
/* AERE crypto-agility, step 5 proofs. The D-147 control is the one that matters: the dangerous
|
||||
* step hides at the END of the schedule, and the gate must walk all of it. */
|
||||
package org.hyperledger.besu.consensus.common.bft;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Properties;
|
||||
import java.util.Set;
|
||||
|
||||
import org.apache.tuweni.bytes.Bytes;
|
||||
import org.hyperledger.besu.crypto.SecureRandomProvider;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class PqSchemeScheduleTest {
|
||||
|
||||
private static final String FALCON = "falcon-512";
|
||||
private static final String SLHDSA = "slh-dsa-128s";
|
||||
|
||||
private final SecureRandom random = SecureRandomProvider.createSecureRandom();
|
||||
|
||||
// ------------------------------------------------------------------ parse + schemesAt
|
||||
|
||||
@Test
|
||||
void schedulesParseAndAnswerByHeight() {
|
||||
final PqSchemeSchedule orar =
|
||||
PqSchemeSchedule.parse("100:falcon-512,200:falcon-512+slh-dsa-128s");
|
||||
assertThat(orar.schemesAt(99)).isEmpty(); // inainte de prima treapta: v2 nearmat
|
||||
assertThat(orar.schemesAt(100)).containsExactlyInAnyOrder(FALCON); // exact pe granita
|
||||
assertThat(orar.schemesAt(150)).containsExactlyInAnyOrder(FALCON);
|
||||
assertThat(orar.schemesAt(200)).containsExactlyInAnyOrder(FALCON, SLHDSA); // hibridul
|
||||
assertThat(orar.schemesAt(1_000_000)).containsExactlyInAnyOrder(FALCON, SLHDSA);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ refuzuri de parse
|
||||
|
||||
@Test
|
||||
void unknownSchemeAnywhereRefusesTheWholeSchedule() {
|
||||
assertThatThrownBy(() -> PqSchemeSchedule.parse("100:falcon-512,200:dilithium-notyet"))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("dilithium-notyet");
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonIncreasingHeightsRefuse() {
|
||||
assertThatThrownBy(() -> PqSchemeSchedule.parse("200:falcon-512,100:falcon-512"))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("strictly increase");
|
||||
assertThatThrownBy(() -> PqSchemeSchedule.parse("200:falcon-512,200:slh-dsa-128s"))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("strictly increase");
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyAndMalformedStepsRefuse() {
|
||||
assertThatThrownBy(() -> PqSchemeSchedule.parse("")).isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> PqSchemeSchedule.parse("100")).isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> PqSchemeSchedule.parse("abc:falcon-512"))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> PqSchemeSchedule.parse("100:"))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> PqSchemeSchedule.parse("100:falcon-512+falcon-512"))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("repeats");
|
||||
assertThatThrownBy(() -> PqSchemeSchedule.parse("-5:falcon-512"))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- poarta de armare (D-147)
|
||||
|
||||
private HybridSignerRegistry registruCuAcoperire(final int falconi, final int slhuri) {
|
||||
final Properties p = new Properties();
|
||||
final int count = Math.max(falconi, Math.max(slhuri, 1));
|
||||
p.setProperty("formatVersion", "hybrid-1");
|
||||
p.setProperty("chainId", "2800");
|
||||
p.setProperty("count", String.valueOf(count));
|
||||
for (int i = 0; i < count; i++) {
|
||||
p.setProperty(i + ".addr", "0x" + String.format("%040x", 0xB0 + i));
|
||||
// fiecare index primeste macar o cheie; acoperirea per schema e controlata mai jos
|
||||
if (i < falconi) {
|
||||
p.setProperty(i + ".key." + FALCON,
|
||||
Bytes.wrap(SealSchemes.FALCON_512.generate(random).publicRegistryForm()).toHexString());
|
||||
}
|
||||
if (i < slhuri) {
|
||||
p.setProperty(i + ".key." + SLHDSA,
|
||||
Bytes.wrap(SealSchemes.SLH_DSA_128S.generate(random).publicRegistryForm()).toHexString());
|
||||
}
|
||||
if (i >= falconi && i >= slhuri) {
|
||||
p.setProperty(i + ".key." + FALCON,
|
||||
Bytes.wrap(SealSchemes.FALCON_512.generate(random).publicRegistryForm()).toHexString());
|
||||
}
|
||||
}
|
||||
return HybridSignerRegistry.fromProperties(p, "test");
|
||||
}
|
||||
|
||||
@Test
|
||||
void armabilityGateWalksTheWholeScheduleNotJustTheFirstStep() {
|
||||
// the registry: 3 validators with Falcon, only 1 with SLH-DSA
|
||||
final HybridSignerRegistry reg = registruCuAcoperire(3, 1);
|
||||
// treapta PERICULOASA e ULTIMA: hibridul cere SLH-DSA cu acoperire 1 < K=3
|
||||
final PqSchemeSchedule orar =
|
||||
PqSchemeSchedule.parse("100:falcon-512,999999:falcon-512+slh-dsa-128s");
|
||||
final var refusal = orar.firstUnsatisfied(reg, 3);
|
||||
assertThat(refusal).isPresent();
|
||||
assertThat(refusal.get()).contains("999999").contains(SLHDSA).contains("covers only 1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void armabilityPassesWhenEverySchemeHasCoverage() {
|
||||
final HybridSignerRegistry reg = registruCuAcoperire(3, 3);
|
||||
final PqSchemeSchedule orar =
|
||||
PqSchemeSchedule.parse("100:falcon-512,200:falcon-512+slh-dsa-128s");
|
||||
assertThat(orar.firstUnsatisfied(reg, 3)).isEmpty();
|
||||
// and the same gate's negative control: an impossible threshold must refuse
|
||||
assertThat(orar.firstUnsatisfied(reg, 4)).isPresent();
|
||||
}
|
||||
|
||||
@Test
|
||||
void beforeTheFirstStepMeansLegacyNotSomeDefaultScheme() {
|
||||
final PqSchemeSchedule orar = PqSchemeSchedule.parse("500:falcon-512");
|
||||
assertThat(orar.schemesAt(0)).isEmpty();
|
||||
assertThat(orar.schemesAt(499)).isEmpty();
|
||||
assertThat(orar.steps()).hasSize(1);
|
||||
assertThat(orar.steps().get(0).schemeIds()).isEqualTo(Set.of(FALCON));
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright contributors to Besu / AERE Network.
|
||||
* Copyright contributors to Besu / Aere Network.
|
||||
*
|
||||
* 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
|
||||
@ -56,15 +56,15 @@ import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.quality.Strictness;
|
||||
|
||||
/**
|
||||
* D-141. THE SECOND HALF OF THE FLEET-RESTART CHAIN DEATH: the heard seals themselves.
|
||||
* THE SECOND HALF OF THE FLEET-RESTART CHAIN DEATH: the heard seals themselves.
|
||||
*
|
||||
* <p>MEASURED ON A NETWORK FIRST, NOT ASSUMED. With the anchor armed at K>0, a SIMULTANEOUS
|
||||
* restart of all seven validators kills the chain permanently (rehearsal
|
||||
* repetitie-activare-2026-08-05, isolated chain 330858). The FIRST half of that deadlock was the
|
||||
* registry, repaired the same day: it now activates at start-up from chain-head state, and all
|
||||
* seven nodes reported "anchor activation at STARTUP from head state: SUCCEEDED". The chain died
|
||||
* anyway. The refusal only changed shape, from "registry address-bound=false" to "registry
|
||||
* address-bound=TRUE ... Heard 0 seal(s)", frozen 150 s then 298 s.
|
||||
* restart of all seven validators kills the chain permanently, measured on an isolated rehearsal
|
||||
* network. The FIRST half of that deadlock was the registry, repaired the same day: it now
|
||||
* activates at start-up from chain-head state, and all seven nodes reported
|
||||
* "anchor activation at STARTUP from head state: SUCCEEDED". The chain died anyway. The refusal
|
||||
* only changed shape, from "registry address-bound=false" to "registry address-bound=TRUE ...
|
||||
* Heard 0 seal(s)", frozen 150 s then 298 s.
|
||||
*
|
||||
* <p>THE SECOND CIRCLE. The Falcon seals over M(head) travel on nothing but the Commit messages of
|
||||
* the head block, and those are never replayed after a restart. They exist nowhere else: the head's
|
||||
@ -100,8 +100,6 @@ import org.mockito.quality.Strictness;
|
||||
* real simultaneous restart with a binary built from this tree. That needs the rehearsal network and
|
||||
* is separate evidence. This class measures every decision that recovery depends on.
|
||||
*/
|
||||
// The D-141 label is our internal finding id. It names a fact about this
|
||||
// code, not anything outside it.
|
||||
public class PqSealPersistenceTest {
|
||||
|
||||
/** Anchor activation height H. */
|
||||
@ -113,7 +111,7 @@ public class PqSealPersistenceTest {
|
||||
/** Height from which the staged threshold K is in force. */
|
||||
private static final long K_AT = H + 10L;
|
||||
|
||||
/** The founder's decision of 2026-08-05: N=7 stays, and K=3 is the value with full margin. */
|
||||
/** The threshold with full margin at N=7: K=3, so the margin equals f. */
|
||||
private static final int K = 3;
|
||||
|
||||
private static final int N = 7;
|
||||
@ -138,7 +136,7 @@ public class PqSealPersistenceTest {
|
||||
public void setUp() throws Exception {
|
||||
dataDirectory = Files.createDirectories(tmp.resolve("besu-data"));
|
||||
|
||||
// AERE D-146 (2026-08-06): v2, proof-bound, bound at H. See PqV2Fixture.
|
||||
// REGISTRY BINDING: v2, proof-bound, bound at H. See PqV2Fixture.
|
||||
final KeccakDigest kd = new KeccakDigest(256);
|
||||
final StringBuilder manifest =
|
||||
new StringBuilder("{\"config\":{\"aereFalconRegistry\":{")
|
||||
@ -259,8 +257,8 @@ public class PqSealPersistenceTest {
|
||||
/**
|
||||
* Persisting seals is only defensible because a seal is SELF-AUTHENTICATING: it is re-verified at
|
||||
* read, against the anchored registry, over M rebuilt from the head this process just loaded. If
|
||||
* that were not so, the file would be exactly defect A8 in another coat - state believed because
|
||||
* it sits in a file a node can be pointed at.
|
||||
* that were not so, the file would be exactly the unbound-registry defect in another coat -
|
||||
* state believed because it sits in a file a node can be pointed at.
|
||||
*
|
||||
* <p>Three shapes of forgery are in the one file, because "a forged seal" is not one thing:
|
||||
*
|
||||
|
||||
@ -36,7 +36,7 @@ import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
/**
|
||||
* AERE D-B (2026-08-06). THE SILENT DEFERRAL, and it is the worst of the three because nothing shows
|
||||
* AERE SIGNED HEIGHT. THE SILENT DEFERRAL, and it is the worst of the three because nothing shows
|
||||
* it.
|
||||
*
|
||||
* <p>WHAT IS SUPPOSED TO BE TRUE. Every row of a v2 registry carries two signatures - a Falcon
|
||||
@ -46,7 +46,8 @@ import org.junit.jupiter.api.io.TempDir;
|
||||
*
|
||||
* <p>WHAT WAS ACTUALLY TRUE UNTIL THIS FILE. {@code bindHeight} was never compared with the {@code
|
||||
* block} of the schedule entry that puts the registry in force. Not anywhere. The two numbers had
|
||||
* been in the same lexical scope since D-081 and were never put on the same expression.
|
||||
* been in the same lexical scope ever since the schedule became height-indexed, and were never put
|
||||
* on the same expression.
|
||||
*
|
||||
* <p>WHY THE HASH DOES NOT CATCH IT, which is the part that makes this invisible rather than merely
|
||||
* missing. {@code bindHeight} is INSIDE the v2 pre-image, so it is covered by the hash - and that is
|
||||
@ -296,7 +297,7 @@ public class PqSignedHeightTest {
|
||||
// The honest limitation has to be IN the message, or an operator will read this as a
|
||||
// consensus guarantee it is not.
|
||||
.hasMessageContaining("DETECTION on this node only")
|
||||
.hasMessageContaining("on every node and in the same change");
|
||||
.hasMessageContaining("all seven nodes and in the same change");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@ -33,7 +33,7 @@ import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
/**
|
||||
* AERE D-A (2026-08-06). AFTER THE FIRST ROTATION, NO NODE COULD BE RESTARTED WITH ITS OWN CORRECT
|
||||
* AERE ROTATION HISTORY AT STARTUP. AFTER THE FIRST ROTATION, NO NODE COULD BE RESTARTED WITH ITS OWN CORRECT
|
||||
* CONFIGURATION.
|
||||
*
|
||||
* <p>WHAT WAS MEASURED, and it was measured twice: once during the rotation rehearsal on a network
|
||||
@ -45,8 +45,9 @@ import org.junit.jupiter.api.io.TempDir;
|
||||
* <p>THE DEFECT WAS THE ORDER OF TWO BLOCKS OF CODE. {@code
|
||||
* FalconSealSupport.verifyRegistryBindingOrAbort} loaded ONE registry, the primary, and handed it to
|
||||
* the guard. The history list was read FORTY-ONE LINES FURTHER DOWN, to build the height-resolved
|
||||
* set D-081 introduced. So the refusal was thrown before the code that knew the answer had run. The
|
||||
* guard was not wrong about what it compared; it was never shown the other files.
|
||||
* set the height-indexed registry change introduced. So the refusal was thrown before the code that
|
||||
* knew the answer had run. The guard was not wrong about what it compared; it was never shown the
|
||||
* other files.
|
||||
*
|
||||
* <p>WHY IT BITES EXACTLY AFTER A ROTATION AND NEVER BEFORE. The primary registry is the genesis
|
||||
* manifest, and genesis does not change. A rotation adds a SECOND entry to {@code
|
||||
|
||||
@ -37,13 +37,13 @@ import org.bouncycastle.pqc.crypto.falcon.FalconPublicKeyParameters;
|
||||
import org.bouncycastle.pqc.crypto.falcon.FalconSigner;
|
||||
|
||||
/**
|
||||
* AERE D-146 (2026-08-06). The shared probe fleet every arming fixture is now built from, and the
|
||||
* reason it had to exist.
|
||||
* AERE REGISTRY BINDING (2026-08-06). The shared probe fleet every arming fixture is now built
|
||||
* from, and the reason it had to exist.
|
||||
*
|
||||
* <p>WHAT IT REPLACED, and why the replacement is not cosmetic. Until 2026-08-06 seven separate
|
||||
* fixtures built their registries around addresses spelled {@code String.format("0x%040x", 0xA00 +
|
||||
* i)}. Those addresses are arithmetic, not keys: no secp256k1 private key produces them, so no
|
||||
* validator can ever sign a D-146 claim for one. The moment {@code AERE-PQC-REG-ARM-02} was wired
|
||||
* validator can ever sign a binding claim for one. The moment {@code AERE-PQC-REG-ARM-02} was wired
|
||||
* into {@code FalconSealSupport}, all seven fixtures described a fleet that CANNOT EXIST - armed,
|
||||
* and provably unable to produce the registry the arming path now requires. Measured on 2026-08-06:
|
||||
* 35 tests across 7 classes, every failure carrying AERE-PQC-REG-ARM-02.
|
||||
@ -215,8 +215,9 @@ public final class PqV2Fixture {
|
||||
|
||||
/**
|
||||
* The bytes the genesis anchor slot commits to for row {@code i}: {@code address || publicKey},
|
||||
* which is what {@code hashV0Legacy} accumulates. Unchanged by D-146 - the proofs are outside the
|
||||
* legacy pre-image - and kept here so a fixture cannot drift from the row it just wrote.
|
||||
* which is what {@code hashV0Legacy} accumulates. Unchanged by the v2 binding work - the proofs
|
||||
* are outside the legacy pre-image - and kept here so a fixture cannot drift from the row it just
|
||||
* wrote.
|
||||
*
|
||||
* @param i the row index
|
||||
* @return the anchored pre-image bytes for that row
|
||||
|
||||
@ -1,101 +0,0 @@
|
||||
/*
|
||||
* 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 PREPARE (step 2 of the 2026-08-28 design note).
|
||||
*
|
||||
* <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 PreparePqAttachGateTest {
|
||||
|
||||
@AfterEach
|
||||
void clearTheProperty() {
|
||||
System.clearProperty(FalconSealSupport.PREPARE_ATTACH_PROPERTY);
|
||||
}
|
||||
|
||||
@Test
|
||||
void withoutThePropertyTheGateIsClosedForever() {
|
||||
assertThat(FalconSealSupport.prepareAttachBlock()).isEqualTo(Long.MAX_VALUE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aGoodValueIsReadAsGiven() {
|
||||
System.setProperty(FalconSealSupport.PREPARE_ATTACH_PROPERTY, "16500000");
|
||||
assertThat(FalconSealSupport.prepareAttachBlock()).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.PREPARE_ATTACH_PROPERTY, "0");
|
||||
assertThat(FalconSealSupport.prepareAttachBlock()).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void aMISTYPEDValueRefusesLoudly() {
|
||||
for (final String bad : new String[] {"nu-e-numar", "16_500_000", "1e6", "-1", " "}) {
|
||||
System.setProperty(FalconSealSupport.PREPARE_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.prepareAttachBlock()).isEqualTo(Long.MAX_VALUE);
|
||||
continue;
|
||||
}
|
||||
assertThatThrownBy(FalconSealSupport::prepareAttachBlock)
|
||||
.as("the value '%s'", bad)
|
||||
.isInstanceOf(FalconSealSupport.ActivationConfigException.class)
|
||||
.hasMessageContaining("AERE-PQC-PREPARE-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.PREPARE_ATTACH_PROPERTY, "0");
|
||||
assertThat(FalconSealSupport.instance().signPrepare(1L, Bytes32.ZERO)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void thePREPAREGateIsNotTheCOMMITGate() {
|
||||
// If it were the same one, rolling the binary onto the fleet would become a flag day: PREPARE
|
||||
// emission would start the moment commit emission does, and that one is already on since block
|
||||
// 13,889,296 on chain 2800.
|
||||
assertThat(FalconSealSupport.PREPARE_ATTACH_PROPERTY).isNotEqualTo("aere.falcon.attachBlock");
|
||||
System.setProperty(FalconSealSupport.PREPARE_ATTACH_PROPERTY, "16500000");
|
||||
assertThat(FalconSealSupport.prepareAttachBlock()).isEqualTo(16_500_000L);
|
||||
// the commit property stays untouched by the PREPARE one
|
||||
assertThat(System.getProperty("aere.falcon.attachBlock")).isNull();
|
||||
}
|
||||
}
|
||||
@ -1,145 +0,0 @@
|
||||
/* AERE crypto-agility, step 1 proofs. Every green here has a red twin: flipped signatures,
|
||||
* flipped messages, wrong keys, and the cross-scheme controls that are the whole point of the
|
||||
* layer (a Falcon artefact must never verify as SLH-DSA, and vice versa). A layer whose schemes
|
||||
* cannot be told apart would be worse than no layer. */
|
||||
package org.hyperledger.besu.consensus.common.bft;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.hyperledger.besu.crypto.SecureRandomProvider;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class SealSchemeAgilityTest {
|
||||
|
||||
private static final byte[] MESSAGE = "aere anchor commit hash stand-in".getBytes(StandardCharsets.UTF_8);
|
||||
private static final byte[] OTHER_MESSAGE = "a different message entirely....".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
private final SecureRandom random = SecureRandomProvider.createSecureRandom();
|
||||
|
||||
// ------------------------------------------------------------------ per-scheme sign/verify
|
||||
|
||||
@Test
|
||||
void falconSignsAndVerifies() {
|
||||
roundTrip(SealSchemes.FALCON_512);
|
||||
}
|
||||
|
||||
@Test
|
||||
void slhDsaSignsAndVerifies() {
|
||||
roundTrip(SealSchemes.SLH_DSA_128S);
|
||||
}
|
||||
|
||||
private void roundTrip(final SealScheme scheme) {
|
||||
final SealScheme.GeneratedPair pair = scheme.generate(random);
|
||||
final Optional<byte[]> sig = scheme.sign(pair.privateKey(), MESSAGE);
|
||||
assertThat(sig).isPresent();
|
||||
assertThat(scheme.verify(pair.publicKey(), MESSAGE, sig.get())).isTrue();
|
||||
assertThat(scheme.verifyRaw(pair.publicRegistryForm(), MESSAGE, sig.get())).isTrue();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ negative controls
|
||||
|
||||
@Test
|
||||
void flippedSignatureBitIsRejectedByBothSchemes() {
|
||||
for (final SealScheme scheme : SealSchemes.all()) {
|
||||
final SealScheme.GeneratedPair pair = scheme.generate(random);
|
||||
final byte[] sig = scheme.sign(pair.privateKey(), MESSAGE).orElseThrow();
|
||||
sig[sig.length / 2] ^= 0x01;
|
||||
assertThat(scheme.verify(pair.publicKey(), MESSAGE, sig))
|
||||
.as("%s must reject a signature with one flipped bit", scheme.id())
|
||||
.isFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void flippedMessageIsRejectedByBothSchemes() {
|
||||
for (final SealScheme scheme : SealSchemes.all()) {
|
||||
final SealScheme.GeneratedPair pair = scheme.generate(random);
|
||||
final byte[] sig = scheme.sign(pair.privateKey(), MESSAGE).orElseThrow();
|
||||
assertThat(scheme.verify(pair.publicKey(), OTHER_MESSAGE, sig))
|
||||
.as("%s must reject the signature over a different message", scheme.id())
|
||||
.isFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void wrongKeyIsRejectedByBothSchemes() {
|
||||
for (final SealScheme scheme : SealSchemes.all()) {
|
||||
final SealScheme.GeneratedPair signer = scheme.generate(random);
|
||||
final SealScheme.GeneratedPair stranger = scheme.generate(random);
|
||||
final byte[] sig = scheme.sign(signer.privateKey(), MESSAGE).orElseThrow();
|
||||
assertThat(scheme.verify(stranger.publicKey(), MESSAGE, sig))
|
||||
.as("%s must reject a signature under a stranger's key", scheme.id())
|
||||
.isFalse();
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- the point of the layer: cross-scheme
|
||||
|
||||
@Test
|
||||
void falconArtefactsNeverVerifyAsSlhDsa() {
|
||||
final SealScheme.GeneratedPair falcon = SealSchemes.FALCON_512.generate(random);
|
||||
final byte[] falconSig = SealSchemes.FALCON_512.sign(falcon.privateKey(), MESSAGE).orElseThrow();
|
||||
// the raw Falcon key is not even parseable as an SLH-DSA key (896 vs 32 bytes)...
|
||||
assertThat(SealSchemes.SLH_DSA_128S.parsePublicKey(falcon.publicRegistryForm())).isEmpty();
|
||||
// ...and the raw path must answer false, never throw
|
||||
assertThat(SealSchemes.SLH_DSA_128S.verifyRaw(falcon.publicRegistryForm(), MESSAGE, falconSig)).isFalse();
|
||||
// a Falcon PRIVATE handle fed to the SLH-DSA signer must refuse, not sign garbage
|
||||
assertThat(SealSchemes.SLH_DSA_128S.sign(falcon.privateKey(), MESSAGE)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void slhDsaArtefactsNeverVerifyAsFalcon() {
|
||||
final SealScheme.GeneratedPair slh = SealSchemes.SLH_DSA_128S.generate(random);
|
||||
final byte[] slhSig = SealSchemes.SLH_DSA_128S.sign(slh.privateKey(), MESSAGE).orElseThrow();
|
||||
assertThat(SealSchemes.FALCON_512.parsePublicKey(slh.publicRegistryForm())).isEmpty();
|
||||
assertThat(SealSchemes.FALCON_512.verifyRaw(slh.publicRegistryForm(), MESSAGE, slhSig)).isFalse();
|
||||
assertThat(SealSchemes.FALCON_512.sign(slh.privateKey(), MESSAGE)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void crossSchemeHandlesAreRejectedOnVerifyToo() {
|
||||
final SealScheme.GeneratedPair falcon = SealSchemes.FALCON_512.generate(random);
|
||||
final SealScheme.GeneratedPair slh = SealSchemes.SLH_DSA_128S.generate(random);
|
||||
final byte[] falconSig = SealSchemes.FALCON_512.sign(falcon.privateKey(), MESSAGE).orElseThrow();
|
||||
// a foreign PUBLIC handle on verify: false, never a ClassCastException
|
||||
assertThat(SealSchemes.SLH_DSA_128S.verify(falcon.publicKey(), MESSAGE, falconSig)).isFalse();
|
||||
assertThat(SealSchemes.FALCON_512.verify(slh.publicKey(), MESSAGE, falconSig)).isFalse();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ registry and wire form
|
||||
|
||||
@Test
|
||||
void registryFindsSchemesByIdAndWireTag() {
|
||||
assertThat(SealSchemes.byId("falcon-512")).contains(SealSchemes.FALCON_512);
|
||||
assertThat(SealSchemes.byId("slh-dsa-128s")).contains(SealSchemes.SLH_DSA_128S);
|
||||
assertThat(SealSchemes.byWireId((byte) 0x01)).contains(SealSchemes.FALCON_512);
|
||||
assertThat(SealSchemes.byWireId((byte) 0x02)).contains(SealSchemes.SLH_DSA_128S);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownSchemesAreLoudlyAbsentNeverDefaulted() {
|
||||
assertThat(SealSchemes.byId("dilithium-notyet")).isEmpty();
|
||||
assertThat(SealSchemes.byId(null)).isEmpty();
|
||||
// 0x00 is the legacy untagged certificate, deliberately NOT resolvable as a scheme
|
||||
assertThat(SealSchemes.byWireId((byte) 0x00)).isEmpty();
|
||||
assertThat(SealSchemes.byWireId((byte) 0x7f)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void registryFormsHaveTheDocumentedLengths() {
|
||||
// Falcon-512: 896 raw h bytes, the exact form the signer registry stores (measured on the
|
||||
// proof-network registry files). The 897-byte pk(897) = 0x09 || h is the PRECOMPILE input
|
||||
// format, one layer above; the first form of this assertion said 897 and went red, which is
|
||||
// the measurement this comment records. Locking 896 here means a scheme change cannot
|
||||
// silently change what a registry entry means.
|
||||
assertThat(SealSchemes.FALCON_512.publicKeyLength()).isEqualTo(896);
|
||||
assertThat(SealSchemes.FALCON_512.generate(random).publicRegistryForm()).hasSize(896);
|
||||
// SLH-DSA-128s: 32 bytes (PK.seed || PK.root) per FIPS 205.
|
||||
assertThat(SealSchemes.SLH_DSA_128S.publicKeyLength()).isEqualTo(32);
|
||||
assertThat(SealSchemes.SLH_DSA_128S.generate(random).publicRegistryForm()).hasSize(32);
|
||||
}
|
||||
}
|
||||
@ -22,7 +22,6 @@ package org.hyperledger.besu.consensus.qbft.core.network;
|
||||
|
||||
import org.hyperledger.besu.consensus.common.bft.ConsensusRoundIdentifier;
|
||||
import org.hyperledger.besu.consensus.common.bft.FalconSeal;
|
||||
import org.hyperledger.besu.consensus.common.bft.SchemeSeal;
|
||||
import org.hyperledger.besu.consensus.common.bft.network.ValidatorMulticaster;
|
||||
import org.hyperledger.besu.consensus.common.bft.payload.SignedData;
|
||||
import org.hyperledger.besu.consensus.qbft.core.messagedata.CommitMessageData;
|
||||
@ -104,27 +103,8 @@ public class QbftMessageTransmitter {
|
||||
* @param digest the digest
|
||||
*/
|
||||
public void multicastPrepare(final ConsensusRoundIdentifier roundIdentifier, final Hash digest) {
|
||||
multicastPrepare(roundIdentifier, digest, Optional.empty());
|
||||
}
|
||||
|
||||
/**
|
||||
* Multicast a prepare carrying an OPTIONAL post-quantum seal of this node.
|
||||
*
|
||||
* <p>Sigiliul vine GATA CALCULAT de la apelant, si asta nu e comoditate: semnaturile Falcon sunt
|
||||
* randomized, so signing the same message twice yields two different byte strings. If the local
|
||||
* copy and the one on the wire each signed their own, the same validator would produce two
|
||||
* valide si DIFERITE pentru aceeasi runda. Se calculeaza o data, sus, si se trece prin amandoua.
|
||||
*
|
||||
* @param roundIdentifier the round identifier
|
||||
* @param digest the digest
|
||||
* @param falconSeal the seal, or empty
|
||||
*/
|
||||
public void multicastPrepare(
|
||||
final ConsensusRoundIdentifier roundIdentifier,
|
||||
final Hash digest,
|
||||
final Optional<FalconSeal> falconSeal) {
|
||||
try {
|
||||
final Prepare data = messageFactory.createPrepare(roundIdentifier, digest, falconSeal);
|
||||
final Prepare data = messageFactory.createPrepare(roundIdentifier, digest);
|
||||
|
||||
final PrepareMessageData message = PrepareMessageData.create(data);
|
||||
|
||||
@ -161,28 +141,8 @@ public class QbftMessageTransmitter {
|
||||
final Hash digest,
|
||||
final SECPSignature commitSeal,
|
||||
final Optional<FalconSeal> falconSeal) {
|
||||
multicastCommit(roundIdentifier, digest, commitSeal, falconSeal, java.util.List.of());
|
||||
}
|
||||
|
||||
/**
|
||||
* Multicast commit carrying a HYBRID post-quantum certificate: the Falcon seal in its own slot
|
||||
* plus the other schemes' seals alongside it (AERE HIBRID, 2026-08-25).
|
||||
*
|
||||
* @param roundIdentifier the round identifier
|
||||
* @param digest the digest
|
||||
* @param commitSeal the ECDSA commit seal
|
||||
* @param falconSeal the optional parallel Falcon seal
|
||||
* @param extraSeals the non-Falcon scheme seals; empty on every node not hybrid-configured
|
||||
*/
|
||||
public void multicastCommit(
|
||||
final ConsensusRoundIdentifier roundIdentifier,
|
||||
final Hash digest,
|
||||
final SECPSignature commitSeal,
|
||||
final Optional<FalconSeal> falconSeal,
|
||||
final java.util.List<SchemeSeal> extraSeals) {
|
||||
try {
|
||||
final Commit data =
|
||||
messageFactory.createCommit(roundIdentifier, digest, commitSeal, falconSeal, extraSeals);
|
||||
final Commit data = messageFactory.createCommit(roundIdentifier, digest, commitSeal, falconSeal);
|
||||
|
||||
final CommitMessageData message = CommitMessageData.create(data);
|
||||
|
||||
|
||||
@ -22,9 +22,6 @@ 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.common.bft.PqAnchorV2;
|
||||
import org.hyperledger.besu.consensus.common.bft.SchemeSeal;
|
||||
import org.hyperledger.besu.consensus.common.bft.SealSchemes;
|
||||
import org.hyperledger.besu.consensus.common.bft.payload.Payload;
|
||||
import org.hyperledger.besu.consensus.qbft.core.messagedata.QbftV1;
|
||||
import org.hyperledger.besu.crypto.SECPSignature;
|
||||
@ -34,7 +31,6 @@ import org.hyperledger.besu.ethereum.rlp.RLPException;
|
||||
import org.hyperledger.besu.ethereum.rlp.RLPInput;
|
||||
import org.hyperledger.besu.ethereum.rlp.RLPOutput;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.StringJoiner;
|
||||
@ -60,10 +56,6 @@ public class CommitPayload extends QbftPayload {
|
||||
private final Hash digest;
|
||||
private final SECPSignature commitSeal;
|
||||
private final Optional<FalconSeal> falconSeal;
|
||||
// AERE HIBRID (2026-08-25): the NON-Falcon scheme seals of a hybrid certificate. Falcon keeps
|
||||
// living in the legacy slot above, so one signature has exactly one home and the wire format of
|
||||
// a Falcon-only commit is untouched. Empty on every commit the live fleet emits today.
|
||||
private final List<SchemeSeal> extraSeals;
|
||||
|
||||
/**
|
||||
* Instantiates a new Commit payload (no Falcon seal).
|
||||
@ -92,72 +84,10 @@ public class CommitPayload extends QbftPayload {
|
||||
final Hash digest,
|
||||
final SECPSignature commitSeal,
|
||||
final Optional<FalconSeal> falconSeal) {
|
||||
this(roundIdentifier, digest, commitSeal, falconSeal, List.of());
|
||||
}
|
||||
|
||||
/**
|
||||
* Instantiates a new Commit payload carrying a HYBRID post-quantum certificate.
|
||||
*
|
||||
* <p>AERE HIBRID (2026-08-25), the founder's step two of 2026-08-07. A hybrid certificate is
|
||||
* Falcon-512 PLUS a second, structurally unrelated scheme (SLH-DSA/SPHINCS+): if lattices fall
|
||||
* the hash-based one holds, and the reverse. Falcon stays in the legacy slot and the OTHER
|
||||
* schemes travel here, so:
|
||||
*
|
||||
* <ul>
|
||||
* <li>a Falcon-only commit encodes byte-for-byte as it does on the live fleet today, which is
|
||||
* the condition for warming this binary without a coordinated flag day;
|
||||
* <li>the two positions are unambiguous by COUNT (0 trailing elements = no PQ, 1 = Falcon
|
||||
* only, 2 = Falcon + extras), so no clever structural sniffing is needed in a consensus
|
||||
* decoder, where cleverness is how D-235-class mistakes are made;
|
||||
* <li>a signature has exactly ONE home, so the two slots can never disagree about Falcon.
|
||||
* </ul>
|
||||
*
|
||||
* <p>The extras are encoded with {@link PqAnchorV2}, the same scheme-tagged codec the V2 anchor
|
||||
* certificate uses: one vocabulary, one canonicality discipline, one place to get it wrong.
|
||||
*
|
||||
* <p>ADDING THIS ELEMENT IS A CONSENSUS BREAKING CHANGE, exactly as {@link #readFrom(RLPInput)}
|
||||
* warns: an older binary cannot parse a commit that carries it. What protects the fleet is not
|
||||
* leniency, which cannot work, but the EMISSION gate: nothing emits extras until every peer can
|
||||
* read them. Same discipline as the Falcon attachment gate.
|
||||
*
|
||||
* @param roundIdentifier the round identifier
|
||||
* @param digest the digest
|
||||
* @param commitSeal the ECDSA commit seal (decisive)
|
||||
* @param falconSeal the Falcon seal; REQUIRED whenever extras are present
|
||||
* @param extraSeals the non-Falcon scheme seals; empty for every commit on the fleet today
|
||||
*/
|
||||
public CommitPayload(
|
||||
final ConsensusRoundIdentifier roundIdentifier,
|
||||
final Hash digest,
|
||||
final SECPSignature commitSeal,
|
||||
final Optional<FalconSeal> falconSeal,
|
||||
final List<SchemeSeal> extraSeals) {
|
||||
this.roundIdentifier = roundIdentifier;
|
||||
this.digest = digest;
|
||||
this.commitSeal = commitSeal;
|
||||
this.falconSeal = falconSeal == null ? Optional.empty() : falconSeal;
|
||||
this.extraSeals = extraSeals == null ? List.of() : List.copyOf(extraSeals);
|
||||
if (!this.extraSeals.isEmpty()) {
|
||||
// The wire format cannot even REPRESENT extras without a Falcon seal, because the slots are
|
||||
// told apart by count. Refusing here means an object that could not be written correctly
|
||||
// cannot be built at all, instead of failing later at encode time on the consensus path.
|
||||
if (this.falconSeal.isEmpty()) {
|
||||
throw new IllegalArgumentException(
|
||||
"AERE HIBRID: extra scheme seals require the Falcon seal to be present");
|
||||
}
|
||||
for (final SchemeSeal seal : this.extraSeals) {
|
||||
if (seal.getSchemeWireId() == SealSchemes.FALCON_512.wireId()) {
|
||||
throw new IllegalArgumentException(
|
||||
"AERE HIBRID: Falcon belongs in its own slot, not in the extras");
|
||||
}
|
||||
}
|
||||
// Validates canonicality and the seal cap NOW, so a payload that cannot be encoded cannot
|
||||
// exist. PqAnchorV2.encode throws on a non-canonical or oversized certificate.
|
||||
final Bytes unused = PqAnchorV2.encode(this.extraSeals);
|
||||
if (unused.isEmpty()) {
|
||||
throw new IllegalArgumentException("AERE HIBRID: empty encoding of a non-empty certificate");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -219,37 +149,9 @@ public class CommitPayload extends QbftPayload {
|
||||
payloadRlp.leaveList();
|
||||
falconSeal = Optional.of(new FalconSeal(idx, sig));
|
||||
}
|
||||
|
||||
// AERE HIBRID: a SECOND optional element, the non-Falcon scheme seals. Unambiguous by count:
|
||||
// it can only be here if the Falcon element above was already consumed, so the two slots can
|
||||
// never be confused for one another and no structural sniffing is required.
|
||||
List<SchemeSeal> extraSeals = List.of();
|
||||
if (!payloadRlp.isEndOfCurrentList()) {
|
||||
final Bytes extrasRaw = payloadRlp.readAsRlp().raw();
|
||||
try {
|
||||
extraSeals = PqAnchorV2.decode(extrasRaw);
|
||||
} catch (final RuntimeException e) {
|
||||
// A malformed certificate is a malformed MESSAGE. It must surface as an RLP failure so the
|
||||
// gossip layer drops it like any other undecodable commit, never as an unchecked throw on
|
||||
// the consensus path.
|
||||
throw new RLPException("AERE HIBRID: undecodable extra certificate: " + e.getMessage());
|
||||
}
|
||||
if (extraSeals.isEmpty()) {
|
||||
// An empty extras element and an absent one would be two encodings of the same value.
|
||||
throw new RLPException("AERE HIBRID: empty extra certificate must be absent, not empty");
|
||||
}
|
||||
}
|
||||
payloadRlp.leaveList();
|
||||
|
||||
final CommitPayload payload;
|
||||
try {
|
||||
payload =
|
||||
new CommitPayload(roundIdentifier, digest, commitSeal, falconSeal, extraSeals);
|
||||
} catch (final IllegalArgumentException e) {
|
||||
// The constructor's invariants (Falcon not in the extras, extras imply Falcon) are part of
|
||||
// what a valid message is, so a violation is a decode failure, not a crash.
|
||||
throw new RLPException("AERE HIBRID: " + e.getMessage());
|
||||
}
|
||||
final CommitPayload payload = new CommitPayload(roundIdentifier, digest, commitSeal, falconSeal);
|
||||
|
||||
// AERE FIX-MALEABILITATE: exactly one encoding is accepted for a given payload value. This
|
||||
// catches everything the RLP reader itself would tolerate, including any element the decode
|
||||
@ -286,12 +188,6 @@ public class CommitPayload extends QbftPayload {
|
||||
rlpOutput.writeBytes(fs.getSignature());
|
||||
rlpOutput.endList();
|
||||
}
|
||||
// AERE HIBRID: the extras, only when there are any. Absent extras leave the encoding of a
|
||||
// Falcon-only commit byte-for-byte as it is on the live fleet today, which is locked by a
|
||||
// golden vector in CommitPayloadHybridTest.
|
||||
if (!extraSeals.isEmpty()) {
|
||||
rlpOutput.writeRaw(PqAnchorV2.encode(extraSeals));
|
||||
}
|
||||
rlpOutput.endList();
|
||||
}
|
||||
|
||||
@ -327,15 +223,6 @@ public class CommitPayload extends QbftPayload {
|
||||
return falconSeal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the non-Falcon scheme seals of a hybrid certificate.
|
||||
*
|
||||
* @return the extra seals, empty for every commit the live fleet emits today
|
||||
*/
|
||||
public List<SchemeSeal> getExtraSeals() {
|
||||
return extraSeals;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ConsensusRoundIdentifier getRoundIdentifier() {
|
||||
return roundIdentifier;
|
||||
@ -353,13 +240,12 @@ public class CommitPayload extends QbftPayload {
|
||||
return Objects.equals(roundIdentifier, that.roundIdentifier)
|
||||
&& Objects.equals(digest, that.digest)
|
||||
&& Objects.equals(commitSeal, that.commitSeal)
|
||||
&& Objects.equals(falconSeal, that.falconSeal)
|
||||
&& Objects.equals(extraSeals, that.extraSeals);
|
||||
&& Objects.equals(falconSeal, that.falconSeal);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(roundIdentifier, digest, commitSeal, falconSeal, extraSeals);
|
||||
return Objects.hash(roundIdentifier, digest, commitSeal, falconSeal);
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -369,7 +255,6 @@ public class CommitPayload extends QbftPayload {
|
||||
.add("digest=" + digest)
|
||||
.add("commitSeal=" + commitSeal)
|
||||
.add("falconSeal=" + falconSeal)
|
||||
.add("extraSeals=" + extraSeals.size())
|
||||
.toString();
|
||||
}
|
||||
}
|
||||
|
||||
@ -107,27 +107,7 @@ public class MessageFactory {
|
||||
* @return the prepare
|
||||
*/
|
||||
public Prepare createPrepare(final ConsensusRoundIdentifier roundIdentifier, final Hash digest) {
|
||||
return createPrepare(roundIdentifier, digest, Optional.empty());
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Prepare carrying an OPTIONAL post-quantum seal of its author.
|
||||
*
|
||||
* <p>AERE PQ (2026-08-28), pasul 1: firul poate purta sigiliul, si nimic nu il emite inca -
|
||||
* fiecare apel de azi trece prin varianta fara sigiliu de mai sus. Ca la commit, semnatura ECDSA
|
||||
* a autorului acopera INTREG payload-ul, deci si sigiliul, ceea ce leaga indexul revendicat de
|
||||
* identitatea celui care trimite mesajul.
|
||||
*
|
||||
* @param roundIdentifier the round identifier
|
||||
* @param digest the digest
|
||||
* @param falconSeal the author's post-quantum seal, or empty
|
||||
* @return the prepare
|
||||
*/
|
||||
public Prepare createPrepare(
|
||||
final ConsensusRoundIdentifier roundIdentifier,
|
||||
final Hash digest,
|
||||
final Optional<FalconSeal> falconSeal) {
|
||||
final PreparePayload payload = new PreparePayload(roundIdentifier, digest, falconSeal);
|
||||
final PreparePayload payload = new PreparePayload(roundIdentifier, digest);
|
||||
return new Prepare(createSignedMessage(payload));
|
||||
}
|
||||
|
||||
@ -161,29 +141,8 @@ public class MessageFactory {
|
||||
final Hash digest,
|
||||
final SECPSignature commitSeal,
|
||||
final Optional<FalconSeal> falconSeal) {
|
||||
return createCommit(roundIdentifier, digest, commitSeal, falconSeal, java.util.List.of());
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a commit carrying a HYBRID post-quantum certificate: the Falcon seal in its own slot
|
||||
* plus the other schemes alongside it. The whole payload, extras included, is signed by this
|
||||
* node's ECDSA key, so the extras cannot be added or stripped by anyone else.
|
||||
*
|
||||
* @param roundIdentifier the round identifier
|
||||
* @param digest the digest
|
||||
* @param commitSeal the ECDSA commit seal
|
||||
* @param falconSeal the Falcon seal; required whenever extras are present
|
||||
* @param extraSeals the non-Falcon scheme seals
|
||||
* @return the commit
|
||||
*/
|
||||
public Commit createCommit(
|
||||
final ConsensusRoundIdentifier roundIdentifier,
|
||||
final Hash digest,
|
||||
final SECPSignature commitSeal,
|
||||
final Optional<FalconSeal> falconSeal,
|
||||
final java.util.List<org.hyperledger.besu.consensus.common.bft.SchemeSeal> extraSeals) {
|
||||
final CommitPayload payload =
|
||||
new CommitPayload(roundIdentifier, digest, commitSeal, falconSeal, extraSeals);
|
||||
new CommitPayload(roundIdentifier, digest, commitSeal, falconSeal);
|
||||
return new Commit(createSignedMessage(payload));
|
||||
}
|
||||
|
||||
|
||||
@ -1,204 +0,0 @@
|
||||
/*
|
||||
* 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.common.bft.payload.Payload;
|
||||
import org.hyperledger.besu.consensus.qbft.core.messagedata.QbftV1;
|
||||
import org.hyperledger.besu.datatypes.Hash;
|
||||
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 java.util.StringJoiner;
|
||||
|
||||
import org.apache.tuweni.bytes.Bytes;
|
||||
|
||||
/**
|
||||
* The Prepare payload.
|
||||
*
|
||||
* <p>AERE PQ (2026-08-28), step 1 of PREPARE-SI-ROUNDCHANGE-SUB-PQ-PROIECTARE-2026-08-28: a PREPARE
|
||||
* MAY carry an OPTIONAL Falcon-512 seal from its author, appended at the end, exactly as
|
||||
* {@code CommitPayload} does. A PREPARE without a seal encodes <b>byte for byte as upstream</b>,
|
||||
* and that is precisely the property that lets the binary be rolled onto a live fleet without a
|
||||
* flag day.
|
||||
*
|
||||
* <p><b>NOTHING EMITS SUCH A PREPARE YET.</b> This file only makes the wire capable of carrying one
|
||||
* and of refusing a malformed one. Emission is the next step and has its own gate, following the
|
||||
* rule paid for at commit: first the binary everywhere, then emission, and only much later
|
||||
* enforcement.
|
||||
*
|
||||
* <p><b>What the seal signs is NOT this file's business</b>, and the design note states it: its own
|
||||
* domain {@code AERE-PQ-PREPARE-1} over (chainId, number, ROUND, digest). If it signed the same
|
||||
* bytes as a commit seal, a PREPARE seal given honestly could be pasted onto a forged COMMIT and
|
||||
* the enforcement there would accept it.
|
||||
*/
|
||||
public class PreparePayload extends QbftPayload {
|
||||
private static final int TYPE = QbftV1.PREPARE;
|
||||
private final ConsensusRoundIdentifier roundIdentifier;
|
||||
private final Hash digest;
|
||||
private final Optional<FalconSeal> falconSeal;
|
||||
|
||||
/**
|
||||
* Instantiates a new Prepare payload, without a post-quantum seal. Encodes byte-for-byte as
|
||||
* upstream Besu.
|
||||
*
|
||||
* @param roundIdentifier the round identifier
|
||||
* @param digest the digest
|
||||
*/
|
||||
public PreparePayload(final ConsensusRoundIdentifier roundIdentifier, final Hash digest) {
|
||||
this(roundIdentifier, digest, Optional.empty());
|
||||
}
|
||||
|
||||
/**
|
||||
* Instantiates a new Prepare payload carrying an optional Falcon-512 seal of its author.
|
||||
*
|
||||
* @param roundIdentifier the round identifier
|
||||
* @param digest the digest
|
||||
* @param falconSeal the author's post-quantum seal, or empty
|
||||
*/
|
||||
public PreparePayload(
|
||||
final ConsensusRoundIdentifier roundIdentifier,
|
||||
final Hash digest,
|
||||
final Optional<FalconSeal> falconSeal) {
|
||||
this.roundIdentifier = roundIdentifier;
|
||||
this.digest = digest;
|
||||
this.falconSeal = falconSeal == null ? Optional.empty() : falconSeal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read from rlp input and return prepare payload.
|
||||
*
|
||||
* <p>STRICTLY CANONICAL, as in {@code CommitPayload} and for the same reason: a PREPARE is an
|
||||
* AUTHENTICATED message, and the author is recovered from the RE-ENCODED payload, not from the
|
||||
* bytes that arrived. Anything the decoder tolerated silently would give several byte strings that
|
||||
* authenticate to the same validator - that is malleability. Decode, re-encode, and the result
|
||||
* must be exactly what came in.
|
||||
*
|
||||
* @param rlpInput the rlp input
|
||||
* @return the prepare payload
|
||||
* @throws RLPException if the received bytes are not the payload's unique canonical encoding
|
||||
*/
|
||||
public static PreparePayload readFrom(final RLPInput rlpInput) {
|
||||
final RLPInput payloadRlp = rlpInput.readAsRlp();
|
||||
final Bytes received = payloadRlp.raw();
|
||||
|
||||
payloadRlp.enterList();
|
||||
final ConsensusRoundIdentifier roundIdentifier = readConsensusRound(payloadRlp);
|
||||
final Hash digest = Payload.readDigest(payloadRlp);
|
||||
|
||||
// AERE PQ: the OPTIONAL seal [index, signature]. A PREPARE without one ends the list here and
|
||||
// decodes to Optional.empty(), so it stays identical to upstream.
|
||||
Optional<FalconSeal> falconSeal = Optional.empty();
|
||||
if (!payloadRlp.isEndOfCurrentList()) {
|
||||
payloadRlp.enterList();
|
||||
final int idx = payloadRlp.readIntScalar();
|
||||
final Bytes sig = payloadRlp.readBytes();
|
||||
payloadRlp.leaveList();
|
||||
falconSeal = Optional.of(new FalconSeal(idx, sig));
|
||||
}
|
||||
payloadRlp.leaveList();
|
||||
|
||||
final PreparePayload payload = new PreparePayload(roundIdentifier, digest, falconSeal);
|
||||
|
||||
final Bytes reencoded = payload.encoded();
|
||||
if (!reencoded.equals(received)) {
|
||||
throw new RLPException(
|
||||
"Non-canonical Prepare payload encoding: received "
|
||||
+ received.size()
|
||||
+ " bytes, canonical form is "
|
||||
+ reencoded.size()
|
||||
+ " bytes");
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeTo(final RLPOutput rlpOutput) {
|
||||
rlpOutput.startList();
|
||||
writeConsensusRound(rlpOutput);
|
||||
rlpOutput.writeBytes(digest.getBytes());
|
||||
// This method DEFINES the canonical encoding: readFrom refuses anything that does not reproduce
|
||||
// it byte for byte. The seal is written only when present, so a seal-less PREPARE 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();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getMessageType() {
|
||||
return TYPE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets digest.
|
||||
*
|
||||
* @return the digest
|
||||
*/
|
||||
public Hash getDigest() {
|
||||
return digest;
|
||||
}
|
||||
|
||||
/**
|
||||
* The author's post-quantum seal, when the message carries one.
|
||||
*
|
||||
* @return the seal, or empty
|
||||
*/
|
||||
public Optional<FalconSeal> getFalconSeal() {
|
||||
return falconSeal;
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
final PreparePayload that = (PreparePayload) o;
|
||||
return Objects.equals(roundIdentifier, that.roundIdentifier)
|
||||
&& Objects.equals(digest, that.digest)
|
||||
&& Objects.equals(falconSeal, that.falconSeal);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(roundIdentifier, digest, falconSeal);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return new StringJoiner(", ", PreparePayload.class.getSimpleName() + "[", "]")
|
||||
.add("roundIdentifier=" + roundIdentifier)
|
||||
.add("digest=" + digest)
|
||||
.add("falconSeal=" + (falconSeal.isPresent() ? "present" : "absent"))
|
||||
.toString();
|
||||
}
|
||||
}
|
||||
@ -1,372 +0,0 @@
|
||||
/*
|
||||
* 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.statemachine;
|
||||
|
||||
import static org.hyperledger.besu.consensus.qbft.core.validation.ValidatorUtil.isMsgForCurrentHeight;
|
||||
import static org.hyperledger.besu.consensus.qbft.core.validation.ValidatorUtil.isMsgForFutureChainHeight;
|
||||
import static org.hyperledger.besu.consensus.qbft.core.validation.ValidatorUtil.isMsgFromKnownValidator;
|
||||
|
||||
import org.hyperledger.besu.consensus.common.bft.ConsensusRoundIdentifier;
|
||||
import org.hyperledger.besu.consensus.common.bft.FalconSeal;
|
||||
import org.hyperledger.besu.consensus.common.bft.PqSealCache;
|
||||
import org.hyperledger.besu.consensus.common.bft.MessageTracker;
|
||||
import org.hyperledger.besu.consensus.common.bft.events.BlockTimerExpiry;
|
||||
import org.hyperledger.besu.consensus.common.bft.events.RoundExpiry;
|
||||
import org.hyperledger.besu.consensus.common.bft.messagewrappers.BftMessage;
|
||||
import org.hyperledger.besu.consensus.common.bft.statemachine.FutureMessageBuffer;
|
||||
import org.hyperledger.besu.consensus.qbft.core.messagewrappers.Commit;
|
||||
import org.hyperledger.besu.consensus.qbft.core.messagewrappers.Prepare;
|
||||
import org.hyperledger.besu.consensus.qbft.core.messagewrappers.Proposal;
|
||||
import org.hyperledger.besu.consensus.qbft.core.messagewrappers.QbftMessageDecoder;
|
||||
import org.hyperledger.besu.consensus.qbft.core.messagewrappers.RoundChange;
|
||||
import org.hyperledger.besu.consensus.qbft.core.types.QbftBlockCodec;
|
||||
import org.hyperledger.besu.consensus.qbft.core.types.QbftBlockHeader;
|
||||
import org.hyperledger.besu.consensus.qbft.core.types.QbftBlockchain;
|
||||
import org.hyperledger.besu.consensus.qbft.core.types.QbftEventHandler;
|
||||
import org.hyperledger.besu.consensus.qbft.core.types.QbftFinalState;
|
||||
import org.hyperledger.besu.consensus.qbft.core.types.QbftGossiper;
|
||||
import org.hyperledger.besu.consensus.qbft.core.types.QbftMessage;
|
||||
import org.hyperledger.besu.consensus.qbft.core.types.QbftNewChainHead;
|
||||
import org.hyperledger.besu.consensus.qbft.core.types.QbftReceivedMessageEvent;
|
||||
import org.hyperledger.besu.consensus.qbft.core.validation.MessageValidator;
|
||||
import org.hyperledger.besu.consensus.qbft.core.validation.RoundChangeMessageValidator;
|
||||
import org.hyperledger.besu.ethereum.p2p.rlpx.wire.MessageData;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/** The Qbft controller. */
|
||||
public class QbftController implements QbftEventHandler {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(QbftController.class);
|
||||
private final QbftBlockchain blockchain;
|
||||
private final QbftFinalState finalState;
|
||||
private final FutureMessageBuffer<QbftMessage> futureMessageBuffer;
|
||||
private final QbftGossiper gossiper;
|
||||
private final MessageTracker duplicateMessageTracker;
|
||||
private final AtomicBoolean started = new AtomicBoolean(false);
|
||||
private final QbftBlockCodec blockEncoder;
|
||||
private final QbftMessageDecoder messageDecoder = new QbftMessageDecoder();
|
||||
private BaseQbftBlockHeightManager currentHeightManager;
|
||||
private final QbftBlockHeightManagerFactory qbftBlockHeightManagerFactory;
|
||||
|
||||
/**
|
||||
* Instantiates a new Qbft controller.
|
||||
*
|
||||
* @param blockchain the blockchain
|
||||
* @param finalState the qbft final state
|
||||
* @param qbftBlockHeightManagerFactory the qbft block height manager factory
|
||||
* @param gossiper the gossiper
|
||||
* @param duplicateMessageTracker the duplicate message tracker
|
||||
* @param futureMessageBuffer the future message buffer
|
||||
* @param blockEncoder the block encoder
|
||||
*/
|
||||
public QbftController(
|
||||
final QbftBlockchain blockchain,
|
||||
final QbftFinalState finalState,
|
||||
final QbftBlockHeightManagerFactory qbftBlockHeightManagerFactory,
|
||||
final QbftGossiper gossiper,
|
||||
final MessageTracker duplicateMessageTracker,
|
||||
final FutureMessageBuffer<QbftMessage> futureMessageBuffer,
|
||||
final QbftBlockCodec blockEncoder) {
|
||||
|
||||
this.blockchain = blockchain;
|
||||
this.finalState = finalState;
|
||||
this.futureMessageBuffer = futureMessageBuffer;
|
||||
this.gossiper = gossiper;
|
||||
this.duplicateMessageTracker = duplicateMessageTracker;
|
||||
this.qbftBlockHeightManagerFactory = qbftBlockHeightManagerFactory;
|
||||
this.blockEncoder = blockEncoder;
|
||||
}
|
||||
|
||||
private void handleMessage(final QbftMessage message, final boolean isReplayed) {
|
||||
final BftMessage<?> bftMessage = messageDecoder.decode(message, blockEncoder);
|
||||
switch (bftMessage) {
|
||||
case Proposal proposal ->
|
||||
consumeMessage(
|
||||
message, proposal, currentHeightManager::handleProposalPayload, isReplayed);
|
||||
case Prepare prepare ->
|
||||
consumeMessage(message, prepare, currentHeightManager::handlePreparePayload, isReplayed);
|
||||
case Commit commit ->
|
||||
consumeMessage(message, commit, currentHeightManager::handleCommitPayload, isReplayed);
|
||||
case RoundChange roundChange ->
|
||||
consumeMessage(
|
||||
message, roundChange, currentHeightManager::handleRoundChangePayload, isReplayed);
|
||||
default ->
|
||||
throw new IllegalArgumentException(
|
||||
String.format(
|
||||
"Received message with messageCode=%d does not conform to any recognised QBFT message structure",
|
||||
message.getData().getCode()));
|
||||
}
|
||||
}
|
||||
|
||||
private void createNewHeightManager(final QbftBlockHeader parentHeader) {
|
||||
currentHeightManager = qbftBlockHeightManagerFactory.create(parentHeader);
|
||||
}
|
||||
|
||||
private BaseQbftBlockHeightManager getCurrentHeightManager() {
|
||||
return currentHeightManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current chain height.
|
||||
*
|
||||
* @return the current chain height
|
||||
*/
|
||||
public long getCurrentChainHeight() {
|
||||
return getCurrentHeightManager().getChainHeight();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current message validator.
|
||||
*
|
||||
* @return the current message validator, or empty if no round is active
|
||||
*/
|
||||
public Optional<MessageValidator> getCurrentMessageValidator() {
|
||||
return getCurrentHeightManager()
|
||||
.getCurrentRound()
|
||||
.map(QbftRound::getRoundState)
|
||||
.map(RoundState::getValidator);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current round change message validator.
|
||||
*
|
||||
* @return the current round change message validator, or empty if not available
|
||||
*/
|
||||
public Optional<RoundChangeMessageValidator> getCurrentRoundChangeMessageValidator() {
|
||||
return getCurrentHeightManager()
|
||||
.getRoundChangeManager()
|
||||
.map(RoundChangeManager::getRoundChangeMessageValidator);
|
||||
}
|
||||
|
||||
/* Replace the current height manager with a no-op height manager. */
|
||||
private void stopCurrentHeightManager(final QbftBlockHeader parentHeader) {
|
||||
currentHeightManager = qbftBlockHeightManagerFactory.createNoOpBlockHeightManager(parentHeader);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
if (started.compareAndSet(false, true)) {
|
||||
startNewHeightManager(blockchain.getChainHeadHeader());
|
||||
} else {
|
||||
// In normal circumstances the height manager should only be started once. If the caller
|
||||
// has stopped the height manager (e.g. while sync completes) they must call stop() before
|
||||
// starting the height manager again.
|
||||
throw new IllegalStateException(
|
||||
"Attempt to start new height manager without stopping previous manager");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() {
|
||||
if (started.compareAndSet(true, false)) {
|
||||
stopCurrentHeightManager(blockchain.getChainHeadHeader());
|
||||
LOG.debug("QBFT height manager stop");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleMessageEvent(final QbftReceivedMessageEvent msg) {
|
||||
final MessageData data = msg.getMessage().getData();
|
||||
if (!duplicateMessageTracker.hasSeenMessage(data)) {
|
||||
duplicateMessageTracker.addSeenMessage(data);
|
||||
handleMessage(msg.getMessage(), false);
|
||||
} else {
|
||||
LOG.trace("Discarded duplicate message");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume message.
|
||||
*
|
||||
* @param <P> the type parameter of BftMessage
|
||||
* @param message the message
|
||||
* @param bftMessage the bft message
|
||||
* @param handleMessage the handle message
|
||||
* @param isReplayed the message is being replayed
|
||||
*/
|
||||
protected <P extends BftMessage<?>> void consumeMessage(
|
||||
final QbftMessage message,
|
||||
final P bftMessage,
|
||||
final Consumer<P> handleMessage,
|
||||
final boolean isReplayed) {
|
||||
LOG.trace("Received BFT {} message", bftMessage.getClass().getSimpleName());
|
||||
|
||||
// Discard all messages which target the BLOCKCHAIN height (which SHOULD be 1 less than
|
||||
// the currentHeightManager, but CAN be the same directly following import).
|
||||
if (bftMessage.getRoundIdentifier().getSequenceNumber()
|
||||
<= blockchain.getChainHeadBlockNumber()) {
|
||||
// AERE D-227: before the message dies here, keep its Falcon seal if it is still useful.
|
||||
pqSalvageLateSeal(bftMessage);
|
||||
LOG.debug(
|
||||
"Discarding a message which targets a height {} not above current chain height {}.",
|
||||
bftMessage.getRoundIdentifier().getSequenceNumber(),
|
||||
blockchain.getChainHeadBlockNumber());
|
||||
return;
|
||||
}
|
||||
|
||||
if (processMessage(bftMessage, message)) {
|
||||
gossiper.send(message, isReplayed);
|
||||
handleMessage.accept(bftMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AERE D-227 (2026-08-14): keep the Falcon seal of a Commit that arrives AFTER its block was
|
||||
* imported, instead of discarding it with the message.
|
||||
*
|
||||
* <p>Why this exists, measured on chain 2800: a block imports on the quorum-th Commit, and the
|
||||
* Commits of the slowest validators consistently arrive tens of milliseconds later - after the
|
||||
* height gate above starts discarding them. Their Falcon seals never reached the seal cache, so
|
||||
* the proposer of the NEXT block (which reads the cache roughly half a block-period later, plenty
|
||||
* of time) could never carry them. Seal circulation measured per signer: the two slowest-disk
|
||||
* nodes appeared in 3% and 14% of other proposers' certificates while appearing in 100% of their
|
||||
* own. The ECDSA path is unaffected either way - by the time a Commit reaches this branch its
|
||||
* block is already imported.
|
||||
*
|
||||
* <p>What is deliberately NOT relaxed: the message itself still dies. Only the seal is copied
|
||||
* out, and only when ALL of the following hold: the message is a Commit carrying a seal, its
|
||||
* height is EXACTLY the chain head (an older seal can never be asked for again), its digest is
|
||||
* the head's own hash (a losing round or a fork sibling is not ours to keep), and its author is
|
||||
* a known validator (so a non-validator peer cannot write into the cache). A seal that lies
|
||||
* about its signer index still cannot reach a header: the producer Falcon-verifies every cached
|
||||
* seal against the anchored registry before carrying it.
|
||||
*/
|
||||
private void pqSalvageLateSeal(final BftMessage<?> bftMessage) {
|
||||
if (!(bftMessage instanceof Commit commit)) {
|
||||
return;
|
||||
}
|
||||
final Optional<FalconSeal> seal = commit.getFalconSeal();
|
||||
if (seal.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
final long head = blockchain.getChainHeadBlockNumber();
|
||||
if (commit.getRoundIdentifier().getSequenceNumber() != head) {
|
||||
return;
|
||||
}
|
||||
final QbftBlockHeader headHeader = blockchain.getChainHeadHeader();
|
||||
if (!commit.getDigest().equals(headHeader.getHash())) {
|
||||
return;
|
||||
}
|
||||
if (!finalState.getValidators().contains(commit.getAuthor())) {
|
||||
return;
|
||||
}
|
||||
PqSealCache.instance().record(head, headHeader.getHash(), List.of(seal.get()));
|
||||
LOG.trace("AERE D-227: salvaged a late Falcon seal for imported block {}", head);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleNewBlockEvent(final QbftNewChainHead newChainHead) {
|
||||
final QbftBlockHeader newBlockHeader = newChainHead.newChainHeadHeader();
|
||||
final QbftBlockHeader currentMiningParent = getCurrentHeightManager().getParentBlockHeader();
|
||||
LOG.debug(
|
||||
"New chain head detected (block number={})," + " currently mining on top of {}.",
|
||||
newBlockHeader.getNumber(),
|
||||
currentMiningParent.getNumber());
|
||||
if (newBlockHeader.getNumber() < currentMiningParent.getNumber()) {
|
||||
LOG.trace(
|
||||
"Discarding NewChainHead event, was for previous block height. chainHeight={} eventHeight={}",
|
||||
currentMiningParent.getNumber(),
|
||||
newBlockHeader.getNumber());
|
||||
return;
|
||||
}
|
||||
|
||||
if (newBlockHeader.getNumber() == currentMiningParent.getNumber()) {
|
||||
if (newBlockHeader.getHash().equals(currentMiningParent.getHash())) {
|
||||
LOG.trace(
|
||||
"Discarding duplicate NewChainHead event. chainHeight={} newBlockHash={} parentBlockHash={}",
|
||||
newBlockHeader.getNumber(),
|
||||
newBlockHeader.getHash(),
|
||||
currentMiningParent.getHash());
|
||||
} else {
|
||||
LOG.error(
|
||||
"Subsequent NewChainHead event at same block height indicates chain fork. chainHeight={}",
|
||||
currentMiningParent.getNumber());
|
||||
}
|
||||
return;
|
||||
}
|
||||
startNewHeightManager(newBlockHeader);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleBlockTimerExpiry(final BlockTimerExpiry blockTimerExpiry) {
|
||||
final ConsensusRoundIdentifier roundIdentifier = blockTimerExpiry.getRoundIdentifier();
|
||||
// Discard block timer events that target a height already on the blockchain (e.g., block
|
||||
// was imported via peer sync while the timer was pending). Same guard as handleRoundExpiry.
|
||||
if (roundIdentifier.getSequenceNumber() <= blockchain.getChainHeadBlockNumber()) {
|
||||
LOG.debug("Discarding a block-timer which targets a height not above current chain height.");
|
||||
return;
|
||||
}
|
||||
if (isMsgForCurrentHeight(roundIdentifier, getCurrentChainHeight())) {
|
||||
getCurrentHeightManager().handleBlockTimerExpiry(roundIdentifier);
|
||||
} else {
|
||||
LOG.trace(
|
||||
"Block timer event discarded as it is not for current block height chainHeight={} eventHeight={}",
|
||||
getCurrentHeightManager().getChainHeight(),
|
||||
roundIdentifier.getSequenceNumber());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleRoundExpiry(final RoundExpiry roundExpiry) {
|
||||
// Discard all messages which target the BLOCKCHAIN height (which SHOULD be 1 less than
|
||||
// the currentHeightManager, but CAN be the same directly following import).
|
||||
if (roundExpiry.getView().getSequenceNumber() <= blockchain.getChainHeadBlockNumber()) {
|
||||
LOG.debug("Discarding a round-expiry which targets a height not above current chain height.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isMsgForCurrentHeight(roundExpiry.getView(), getCurrentChainHeight())) {
|
||||
getCurrentHeightManager().roundExpired(roundExpiry);
|
||||
} else {
|
||||
LOG.trace(
|
||||
"Round expiry event discarded as it is not for current block height chainHeight={} eventHeight={}",
|
||||
getCurrentHeightManager().getChainHeight(),
|
||||
roundExpiry.getView().getSequenceNumber());
|
||||
}
|
||||
}
|
||||
|
||||
private void startNewHeightManager(final QbftBlockHeader parentHeader) {
|
||||
createNewHeightManager(parentHeader);
|
||||
final long newChainHeight = getCurrentHeightManager().getChainHeight();
|
||||
futureMessageBuffer
|
||||
.retrieveMessagesForHeight(newChainHeight)
|
||||
.forEach(msg -> handleMessage(msg, true));
|
||||
}
|
||||
|
||||
private boolean processMessage(final BftMessage<?> msg, final QbftMessage rawMsg) {
|
||||
final ConsensusRoundIdentifier msgRoundIdentifier = msg.getRoundIdentifier();
|
||||
if (isMsgForCurrentHeight(msg, getCurrentChainHeight())) {
|
||||
return isMsgFromKnownValidator(msg, finalState.getValidators())
|
||||
&& finalState.isLocalNodeValidator();
|
||||
} else if (isMsgForFutureChainHeight(msg, getCurrentChainHeight())) {
|
||||
LOG.trace("Received message for future block height round={}", msgRoundIdentifier);
|
||||
futureMessageBuffer.addMessage(msgRoundIdentifier.getSequenceNumber(), rawMsg);
|
||||
} else {
|
||||
LOG.trace(
|
||||
"BFT message discarded as it is from a previous block height messageType={} chainHeight={} eventHeight={}",
|
||||
msg.getMessageType(),
|
||||
getCurrentHeightManager().getChainHeight(),
|
||||
msgRoundIdentifier.getSequenceNumber());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@ -282,52 +282,16 @@ public class QbftRound {
|
||||
private void sendPrepare(final QbftBlock block) {
|
||||
LOG.debug("Sending prepare message. round={}", roundState.getRoundIdentifier());
|
||||
try {
|
||||
// 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
|
||||
// doua semnari ale aceluiasi mesaj dau doi octeti diferiti; daca fiecare exemplar si-ar semna
|
||||
// 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.
|
||||
final Optional<FalconSeal> falconSeal = prepareSealFor(block);
|
||||
// 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
|
||||
// trebuie sa se comporte identic cu unul de dinaintea acestei schimbari - nu doar sa scrie
|
||||
// aceiasi octeti, ci sa treaca prin aceleasi apeluri. Asa, binarul poate sta pe flota fara
|
||||
// so that nothing changes until a decision is made. Same pattern as commit.
|
||||
final Prepare localPrepareMessage =
|
||||
falconSeal.isPresent()
|
||||
? messageFactory.createPrepare(getRoundIdentifier(), block.getHash(), falconSeal)
|
||||
: messageFactory.createPrepare(getRoundIdentifier(), block.getHash());
|
||||
messageFactory.createPrepare(getRoundIdentifier(), block.getHash());
|
||||
peerIsPrepared(localPrepareMessage);
|
||||
if (falconSeal.isPresent()) {
|
||||
transmitter.multicastPrepare(
|
||||
localPrepareMessage.getRoundIdentifier(), localPrepareMessage.getDigest(), falconSeal);
|
||||
} else {
|
||||
transmitter.multicastPrepare(
|
||||
localPrepareMessage.getRoundIdentifier(), localPrepareMessage.getDigest());
|
||||
}
|
||||
transmitter.multicastPrepare(
|
||||
localPrepareMessage.getRoundIdentifier(), localPrepareMessage.getDigest());
|
||||
} catch (final SecurityModuleException e) {
|
||||
LOG.warn("Failed to create a signed Prepare; {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sigiliul post-cuantic al acestui nod pentru PREPARE-ul blocului dat, sau gol.
|
||||
*
|
||||
* <p>Mesajul semnat are DOMENIUL LUI si contine RUNDA - vezi PqAnchor.prepareMessage si
|
||||
* proiectarea din 2026-08-28. Cu domeniul commitului, un sigiliu de PREPARE dat cinstit ar putea
|
||||
* fi lipit pe un COMMIT falsificat.
|
||||
*/
|
||||
private Optional<FalconSeal> prepareSealFor(final QbftBlock block) {
|
||||
final long blockNumber = block.getHeader().getNumber();
|
||||
final Bytes32 message =
|
||||
PqAnchor.prepareMessage(
|
||||
PqAnchorProducer.config().chainId(),
|
||||
blockNumber,
|
||||
getRoundIdentifier().getRoundNumber(),
|
||||
block.getHash().getBytes());
|
||||
return FalconSealSupport.instance().signPrepare(blockNumber, message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle prepare message.
|
||||
*
|
||||
@ -383,17 +347,11 @@ public class QbftRound {
|
||||
return true;
|
||||
}
|
||||
final Optional<FalconSeal> falconSeal = falconSealFor(block, commitHash);
|
||||
// AERE HYBRID: the other schemes' extras, over the SAME message; empty on any node today.
|
||||
final java.util.List<org.hyperledger.besu.consensus.common.bft.SchemeSeal> extraSeals =
|
||||
falconSeal.isPresent() ? extraSealsFor(block, commitHash) : java.util.List.of();
|
||||
|
||||
// There are times handling a proposed block is enough to enter prepared.
|
||||
if (wasPrepared != roundState.isPrepared()) {
|
||||
LOG.debug("Sending commit message. round={}", roundState.getRoundIdentifier());
|
||||
if (!extraSeals.isEmpty()) {
|
||||
transmitter.multicastCommit(
|
||||
getRoundIdentifier(), block.getHash(), commitSeal, falconSeal, extraSeals);
|
||||
} else if (falconSeal.isPresent()) {
|
||||
if (falconSeal.isPresent()) {
|
||||
transmitter.multicastCommit(getRoundIdentifier(), block.getHash(), commitSeal, falconSeal);
|
||||
} else {
|
||||
transmitter.multicastCommit(getRoundIdentifier(), block.getHash(), commitSeal);
|
||||
@ -410,8 +368,7 @@ public class QbftRound {
|
||||
roundState.getRoundIdentifier(),
|
||||
msg.getBlock().getHash(),
|
||||
commitSeal,
|
||||
falconSeal,
|
||||
extraSeals)
|
||||
falconSeal)
|
||||
: messageFactory.createCommit(
|
||||
roundState.getRoundIdentifier(), msg.getBlock().getHash(), commitSeal);
|
||||
roundState.addCommitMessage(localCommitMessage);
|
||||
@ -441,13 +398,7 @@ public class QbftRound {
|
||||
final Hash commitHash = commitHashFor(block);
|
||||
final SECPSignature commitSeal = nodeKey.sign(Bytes32.wrap(commitHash.getBytes()));
|
||||
final Optional<FalconSeal> falconSeal = falconSealFor(block, commitHash);
|
||||
// AERE HIBRID: aceleasi extrase si pe drumul tarziu, ca cele doua locuri sa nu divearga.
|
||||
final java.util.List<org.hyperledger.besu.consensus.common.bft.SchemeSeal> extraSeals =
|
||||
falconSeal.isPresent() ? extraSealsFor(block, commitHash) : java.util.List.of();
|
||||
if (!extraSeals.isEmpty()) {
|
||||
transmitter.multicastCommit(
|
||||
getRoundIdentifier(), block.getHash(), commitSeal, falconSeal, extraSeals);
|
||||
} else if (falconSeal.isPresent()) {
|
||||
if (falconSeal.isPresent()) {
|
||||
transmitter.multicastCommit(getRoundIdentifier(), block.getHash(), commitSeal, falconSeal);
|
||||
} else {
|
||||
transmitter.multicastCommit(getRoundIdentifier(), block.getHash(), commitSeal);
|
||||
@ -482,14 +433,8 @@ public class QbftRound {
|
||||
private void importBlockToChain() {
|
||||
|
||||
// AERE hybrid PQC: pass the gossiped Falcon seals collected from commit messages so the block
|
||||
// assembler can embed the post-quantum certificate. When no Falcon seals were gossiped (Falcon
|
||||
// disabled), fall back to the unchanged ECDSA-only sealing path.
|
||||
//
|
||||
// WHAT GETS EMBEDDED, corrected 2026-08-19 (finding D-235): NOT a per-block 2f+1 quorum. The
|
||||
// assembler writes a certificate only at an anchor height, over the PARENT, and it needs at
|
||||
// least K valid seals, K being the configured schedule (6 of 9 on chain 2800 today). Between
|
||||
// anchor heights nothing is written. The older wording here said "a >= 2f+1 Falcon quorum
|
||||
// certificate" and described the legacy per-block rule, which is retired at the anchor block.
|
||||
// assembler can embed a >= 2f+1 Falcon quorum certificate. When no Falcon seals were gossiped
|
||||
// (Falcon disabled), fall back to the unchanged ECDSA-only sealing path.
|
||||
final Collection<FalconSeal> falconSeals = roundState.getFalconSeals();
|
||||
final QbftBlock blockToImport =
|
||||
falconSeals.isEmpty()
|
||||
@ -564,31 +509,6 @@ public class QbftRound {
|
||||
return pqOnchainHash;
|
||||
}
|
||||
|
||||
/**
|
||||
* AERE HIBRID (2026-08-25): the non-Falcon seals of this node's hybrid certificate, over the
|
||||
* SAME message the Falcon seal signs (the two are one certificate; two messages would be two
|
||||
* certificates and the verifier could not bind them). Empty on every node that is not
|
||||
* hybrid-configured, and below the emission gate: {@link HybridSealProducer} never throws and
|
||||
* never emits half a certificate.
|
||||
*/
|
||||
private java.util.List<org.hyperledger.besu.consensus.common.bft.SchemeSeal> extraSealsFor(
|
||||
final QbftBlock block, final Hash commitHash) {
|
||||
return org.hyperledger.besu.consensus.common.bft.HybridSealSupport.instance()
|
||||
.producer()
|
||||
.sealsFor(block.getHeader().getNumber(), pqSealMessageFor(block, commitHash));
|
||||
}
|
||||
|
||||
/** The exact bytes a PQ seal over this block signs; shared by Falcon and the hybrid extras,
|
||||
* so the two halves of a hybrid certificate can never drift onto different messages. */
|
||||
private Bytes32 pqSealMessageFor(final QbftBlock block, final Hash commitHash) {
|
||||
final long blockNumber = block.getHeader().getNumber();
|
||||
if (PqAnchorProducer.sealMessageIsAnchorForm(blockNumber)) {
|
||||
return PqAnchor.commitMessage(
|
||||
PqAnchorProducer.config().chainId(), blockNumber, pqOnchainHashOf(block).getBytes());
|
||||
}
|
||||
return Bytes32.wrap(commitHash.getBytes());
|
||||
}
|
||||
|
||||
private Optional<FalconSeal> falconSealFor(final QbftBlock block, final Hash commitHash) {
|
||||
// FalconSealSupport.sign never throws (any fault is swallowed and logged), and returns empty
|
||||
// when this node holds no Falcon signing key, so the ECDSA commit path is never affected.
|
||||
@ -607,10 +527,16 @@ public class QbftRound {
|
||||
// not rebuild it. Every node flips at the same height, since the height is a pure function of
|
||||
// the same configured H; a node configured with a different H emits seals nobody can use, and
|
||||
// the producer drops them on verification rather than carrying them into a header.
|
||||
// AERE HIBRID (2026-08-25): mesajul se calculeaza acum intr-UN singur loc, pqSealMessageFor,
|
||||
// impartit cu extrasele hibride; doua copii ale acestei logici ar fi divergat intr-o zi.
|
||||
final long blockNumber = block.getHeader().getNumber();
|
||||
return FalconSealSupport.instance().sign(blockNumber, pqSealMessageFor(block, commitHash));
|
||||
final Bytes32 message;
|
||||
if (PqAnchorProducer.sealMessageIsAnchorForm(blockNumber)) {
|
||||
message =
|
||||
PqAnchor.commitMessage(
|
||||
PqAnchorProducer.config().chainId(), blockNumber, pqOnchainHashOf(block).getBytes());
|
||||
} else {
|
||||
message = Bytes32.wrap(commitHash.getBytes());
|
||||
}
|
||||
return FalconSealSupport.instance().sign(blockNumber, message);
|
||||
}
|
||||
|
||||
private QbftBlock createCommitBlock(final QbftBlock block) {
|
||||
|
||||
@ -1,180 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 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
|
||||
*
|
||||
* Modifications Copyright contributors to the Aere Network.
|
||||
*
|
||||
* This file was modified by contributors to the Aere Network, as required by section 4(b) of the
|
||||
* Apache License 2.0. The copyright header above is the upstream one and is left exactly as it was
|
||||
* found, as section 4(c) requires. The change: an OPTIONAL, height-gated post-quantum enforcement
|
||||
* hook (see PqCommitEnforcement). When no enforcement is supplied, behaviour is byte-for-byte the
|
||||
* upstream behaviour; the existing constructor keeps that contract for every existing caller.
|
||||
*/
|
||||
package org.hyperledger.besu.consensus.qbft.core.validation;
|
||||
|
||||
import org.hyperledger.besu.consensus.common.bft.ConsensusRoundIdentifier;
|
||||
import org.hyperledger.besu.consensus.common.bft.payload.SignedData;
|
||||
import org.hyperledger.besu.consensus.qbft.core.messagewrappers.Commit;
|
||||
import org.hyperledger.besu.consensus.qbft.core.payload.CommitPayload;
|
||||
import org.hyperledger.besu.datatypes.Address;
|
||||
import org.hyperledger.besu.datatypes.Hash;
|
||||
import org.hyperledger.besu.ethereum.core.Util;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/** The Commit validator. */
|
||||
public class CommitValidator {
|
||||
|
||||
private static final String ERROR_PREFIX = "Invalid Commit Message";
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(CommitValidator.class);
|
||||
|
||||
private final Collection<Address> validators;
|
||||
private final ConsensusRoundIdentifier targetRound;
|
||||
private final Hash expectedDigest;
|
||||
private final Hash expectedCommitDigest;
|
||||
// AERE full-PQ: optional height-gated enforcement. Null means "upstream behaviour", which is
|
||||
// exactly what the pre-existing constructor supplies, so nothing changes for existing callers.
|
||||
private final PqCommitEnforcement pqEnforcement;
|
||||
|
||||
/**
|
||||
* Instantiates a new Commit validator, self-wiring the AERE post-quantum enforcement from the
|
||||
* system configuration.
|
||||
*
|
||||
* <p>With {@code aere.pq.commitPq.forkBlock} absent -- every fleet node today, and every test
|
||||
* JVM -- this is byte-for-byte the upstream behaviour. With it set, commits at or above that
|
||||
* height only count with a valid post-quantum seal of their own author. A present but broken
|
||||
* value refuses loudly here rather than silently disarming.
|
||||
*
|
||||
* @param validators the validators
|
||||
* @param targetRound the target round
|
||||
* @param expectedDigest the expected digest
|
||||
* @param expectedCommitDigest the expected commit digest
|
||||
*/
|
||||
public CommitValidator(
|
||||
final Collection<Address> validators,
|
||||
final ConsensusRoundIdentifier targetRound,
|
||||
final Hash expectedDigest,
|
||||
final Hash expectedCommitDigest) {
|
||||
this(
|
||||
validators,
|
||||
targetRound,
|
||||
expectedDigest,
|
||||
expectedCommitDigest,
|
||||
PqCommitEnforcement.fromSystemConfig());
|
||||
}
|
||||
|
||||
/**
|
||||
* Instantiates a new Commit validator with optional post-quantum enforcement.
|
||||
*
|
||||
* <p>AERE full-PQ: when {@code pqEnforcement} is non-null and armed at this round's height, a
|
||||
* Commit only validates if it carries a post-quantum seal whose index is bound to the message
|
||||
* author and whose signature verifies over the commit digest. A vote without valid PQ does not
|
||||
* count toward quorum.
|
||||
*
|
||||
* @param validators the validators
|
||||
* @param targetRound the target round
|
||||
* @param expectedDigest the expected digest
|
||||
* @param expectedCommitDigest the expected commit digest
|
||||
* @param pqEnforcement the height-gated enforcement, or null for upstream behaviour
|
||||
*/
|
||||
public CommitValidator(
|
||||
final Collection<Address> validators,
|
||||
final ConsensusRoundIdentifier targetRound,
|
||||
final Hash expectedDigest,
|
||||
final Hash expectedCommitDigest,
|
||||
final PqCommitEnforcement pqEnforcement) {
|
||||
this.validators = validators;
|
||||
this.targetRound = targetRound;
|
||||
this.expectedDigest = expectedDigest;
|
||||
this.expectedCommitDigest = expectedCommitDigest;
|
||||
this.pqEnforcement = pqEnforcement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate.
|
||||
*
|
||||
* @param msg the msg
|
||||
* @return the boolean
|
||||
*/
|
||||
public boolean validate(final Commit msg) {
|
||||
return validate(msg.getSignedPayload());
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate.
|
||||
*
|
||||
* @param signedPayload the signed payload
|
||||
* @return the boolean
|
||||
*/
|
||||
public boolean validate(final SignedData<CommitPayload> signedPayload) {
|
||||
if (!validators.contains(signedPayload.getAuthor())) {
|
||||
LOG.info("{}: did not originate from a recognized validator.", ERROR_PREFIX);
|
||||
return false;
|
||||
}
|
||||
|
||||
final CommitPayload payload = signedPayload.getPayload();
|
||||
|
||||
if (!payload.getRoundIdentifier().equals(targetRound)) {
|
||||
LOG.info(
|
||||
"{}: did not target expected round {} was {}",
|
||||
ERROR_PREFIX,
|
||||
targetRound,
|
||||
payload.getRoundIdentifier());
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!payload.getDigest().equals(expectedDigest)) {
|
||||
LOG.info(
|
||||
"{}: did not contain expected digest {} was {}",
|
||||
ERROR_PREFIX,
|
||||
expectedDigest,
|
||||
payload.getDigest());
|
||||
return false;
|
||||
}
|
||||
|
||||
final Address commitSealCreator =
|
||||
Util.signatureToAddress(payload.getCommitSeal(), expectedCommitDigest);
|
||||
|
||||
if (!commitSealCreator.equals(signedPayload.getAuthor())) {
|
||||
LOG.info(
|
||||
"{}: Seal was not created by the message transmitter {} was {}",
|
||||
ERROR_PREFIX,
|
||||
commitSealCreator,
|
||||
signedPayload.getAuthor());
|
||||
return false;
|
||||
}
|
||||
|
||||
// AERE full-PQ: above the arming height a commit vote only counts with a valid post-quantum
|
||||
// seal bound to this very author. Below it (or with no enforcement supplied) nothing changes.
|
||||
if (pqEnforcement != null) {
|
||||
final Optional<String> refusal =
|
||||
pqEnforcement.refusal(
|
||||
targetRound.getSequenceNumber(),
|
||||
signedPayload.getAuthor(),
|
||||
expectedCommitDigest,
|
||||
payload.getFalconSeal(),
|
||||
payload.getExtraSeals());
|
||||
if (refusal.isPresent()) {
|
||||
LOG.info("{}: {}", ERROR_PREFIX, refusal.get());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@ -1,332 +0,0 @@
|
||||
/*
|
||||
* AERE full-PQ consensus (TOP 3 list, item 1), step 1: the enforcement core.
|
||||
*
|
||||
* WHAT IT DECIDES. From the arming height upward, a Commit message counts toward the 2f+1
|
||||
* quorum ONLY if it carries the PQ seal (already transported in CommitPayload, live on the
|
||||
* fleet) and the seal (a) exists, (b) has its index bound to the VERY author of the message
|
||||
* through the height-indexed registry, (c) verifies over the commit digest. Without a valid
|
||||
* PQ seal the vote does not count -- this puts post-quantum into the agreement itself, at the
|
||||
* layer where the D-235 header rule could not live (headers between anchors carry no seals;
|
||||
* the commit message can carry them all).
|
||||
*
|
||||
* HOW IT IS WIRED (updated the same night). CommitValidator calls it through the production
|
||||
* constructor, which self-installs from fromSystemConfig(): absent property = null =
|
||||
* upstream behaviour, DISARMED by default; a broken value = loud refusal, never a silent
|
||||
* disarm. The core stays purely testable: the registry is injected (the PqSignerRegistry
|
||||
* interface, which refuses by construction to answer without a height -- the D2 inheritance);
|
||||
* the singleton enters only through the production factory, exactly like PqAnchorSealsRule.
|
||||
*/
|
||||
package org.hyperledger.besu.consensus.qbft.core.validation;
|
||||
|
||||
import org.hyperledger.besu.consensus.common.bft.FalconSeal;
|
||||
import org.hyperledger.besu.consensus.common.bft.HybridSealSupport;
|
||||
import org.hyperledger.besu.consensus.common.bft.HybridSignerRegistry;
|
||||
import org.hyperledger.besu.consensus.common.bft.PqSchemeSchedule;
|
||||
import org.hyperledger.besu.consensus.common.bft.PqSignerRegistry;
|
||||
import org.hyperledger.besu.consensus.common.bft.SchemeSeal;
|
||||
import org.hyperledger.besu.consensus.common.bft.SealScheme;
|
||||
import org.hyperledger.besu.consensus.common.bft.SealSchemes;
|
||||
import org.hyperledger.besu.datatypes.Address;
|
||||
import org.hyperledger.besu.datatypes.Hash;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
import org.apache.tuweni.bytes.Bytes;
|
||||
|
||||
/** Height-gated decision: does this Commit's PQ seal let it count toward quorum? */
|
||||
public final class PqCommitEnforcement {
|
||||
|
||||
/** The disarmed height: no block ever reaches it, so nothing is enforced. */
|
||||
public static final long DISARMED = Long.MAX_VALUE;
|
||||
|
||||
/**
|
||||
* System property naming the first block height at which a Commit vote no longer counts without
|
||||
* a valid post-quantum seal of its own author. Absent = disarmed, today's behaviour. Delivered
|
||||
* per node through {@code BESU_OPTS}, like every other AERE consensus switch; there is NO
|
||||
* consensus binding on the value, so the fleet must coordinate on it exactly as it does on the
|
||||
* anchor heights. Env: {@code AERE_PQ_COMMITPQ_FORKBLOCK}.
|
||||
*/
|
||||
public static final String PROPERTY_FORK_BLOCK = "aere.pq.commitPq.forkBlock";
|
||||
|
||||
/** Environment fallback for {@link #PROPERTY_FORK_BLOCK}. */
|
||||
public static final String ENV_FORK_BLOCK = "AERE_PQ_COMMITPQ_FORKBLOCK";
|
||||
|
||||
/**
|
||||
* The enforcement the production (4-arg) CommitValidator constructor wires in, read fresh from
|
||||
* the system configuration on every call.
|
||||
*
|
||||
* <p>Absent configuration returns null, which CommitValidator treats as upstream behaviour --
|
||||
* the honest default. A PRESENT but unparseable value REFUSES loudly instead of disarming:
|
||||
* the paid-for lesson of the anchor loader is that a mistyped value must never start a node
|
||||
* silently disarmed ("a mistyped comma boots the node DISARMED"). The throw happens at
|
||||
* validator construction, i.e. at the first round the node processes, which is as close to
|
||||
* startup as this layer can get.
|
||||
*
|
||||
* @return the armed enforcement, or null when the property is not set anywhere
|
||||
* @throws IllegalStateException AERE-PQC-COMMIT-CONF-01 when the value is present but not a
|
||||
* non-negative decimal block height
|
||||
*/
|
||||
public static PqCommitEnforcement fromSystemConfig() {
|
||||
String raw = System.getProperty(PROPERTY_FORK_BLOCK);
|
||||
if (raw == null) {
|
||||
raw = System.getenv(ENV_FORK_BLOCK);
|
||||
}
|
||||
if (raw == null) {
|
||||
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-COMMIT-CONF-01: "
|
||||
+ PROPERTY_FORK_BLOCK
|
||||
+ " is set but not a non-negative block height: '"
|
||||
+ raw
|
||||
+ "'. A mistyped value must refuse, never silently disarm.");
|
||||
}
|
||||
// AERE HYBRID: when the node has the schedule+registry pair configured, enforcement
|
||||
// receives it too, so from the hybrid step of the schedule onward a vote without ALL the
|
||||
// required schemes does not count. Without the pair, this stays exactly the Falcon
|
||||
// enforcement we had until now.
|
||||
final HybridSealSupport hybrid = HybridSealSupport.instance();
|
||||
if (hybrid.schedule().isPresent()) {
|
||||
return new PqCommitEnforcement(
|
||||
armedFrom,
|
||||
PqSignerRegistry.falconSealSupport(),
|
||||
hybrid.schedule().get(),
|
||||
hybrid.registry().orElseThrow());
|
||||
}
|
||||
return new PqCommitEnforcement(armedFrom, PqSignerRegistry.falconSealSupport());
|
||||
}
|
||||
|
||||
private final long armedFromBlock;
|
||||
private final PqSignerRegistry registry;
|
||||
// AERE HIBRID: null in Falcon-only mode, which is every node today.
|
||||
private final PqSchemeSchedule schemeSchedule;
|
||||
private final HybridSignerRegistry hybridRegistry;
|
||||
|
||||
/**
|
||||
* @param armedFromBlock first block height (inclusive) at which enforcement applies; use
|
||||
* {@link #DISARMED} for the today-behaviour
|
||||
* @param registry the height-aware signer registry (injected, never a singleton)
|
||||
*/
|
||||
public PqCommitEnforcement(final long armedFromBlock, final PqSignerRegistry registry) {
|
||||
this(armedFromBlock, registry, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforcement that also demands the HYBRID schemes a schedule requires at each height.
|
||||
*
|
||||
* <p>AERE HIBRID (2026-08-25). Above the arming height a vote must carry, besides the Falcon
|
||||
* seal checked by the Falcon-only path, a valid seal for EVERY other scheme the schedule names
|
||||
* at that height. The point of a hybrid is that the two families fail independently, so a
|
||||
* partially satisfied certificate is worth exactly as much as the weakest family present, which
|
||||
* is why a missing scheme refuses rather than degrades.
|
||||
*
|
||||
* <p>NO REGISTRY-ALIGNMENT ASSUMPTION. The hybrid registry and the legacy Falcon registry are
|
||||
* two files, and the paid-for lesson of D-191 is that a pair of files that must agree will one
|
||||
* day not agree. So this code never assumes their index spaces line up: an extra seal must
|
||||
* carry the SAME validator index as the Falcon seal on the same message, and that index must
|
||||
* resolve, IN THE HYBRID REGISTRY, to the very author of the message. Both facts are checked,
|
||||
* neither is assumed.
|
||||
*
|
||||
* @param armedFromBlock first block height (inclusive) at which enforcement applies
|
||||
* @param registry the height-aware Falcon signer registry
|
||||
* @param schemeSchedule which schemes are required at which height; null for Falcon-only
|
||||
* @param hybridRegistry per-validator public keys per scheme; null for Falcon-only
|
||||
*/
|
||||
public PqCommitEnforcement(
|
||||
final long armedFromBlock,
|
||||
final PqSignerRegistry registry,
|
||||
final PqSchemeSchedule schemeSchedule,
|
||||
final HybridSignerRegistry hybridRegistry) {
|
||||
this.armedFromBlock = armedFromBlock;
|
||||
this.registry = registry;
|
||||
this.schemeSchedule = schemeSchedule;
|
||||
this.hybridRegistry = hybridRegistry;
|
||||
if ((schemeSchedule == null) != (hybridRegistry == null)) {
|
||||
// Half a hybrid configuration is the shape that starts a node believing it enforces
|
||||
// something it does not. Refuse at construction, the same stance as every other AERE gate.
|
||||
throw new IllegalStateException(
|
||||
"AERE-PQC-COMMIT-CONF-03: the scheme schedule and the hybrid registry are a PAIR;"
|
||||
+ " configure both or neither.");
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether enforcement is active at {@code height}. */
|
||||
public boolean armedAt(final long height) {
|
||||
return height >= armedFromBlock;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether a Commit may count toward quorum.
|
||||
*
|
||||
* @param height the block height the commit targets (the round's sequence number)
|
||||
* @param author the RECOVERED author of the signed Commit message (from its ECDSA signature)
|
||||
* @param commitDigest the commit digest the PQ seal must have signed
|
||||
* @param seal the optional PQ seal carried inside the payload
|
||||
* @return empty when the commit counts; otherwise the refusal, with names and numbers
|
||||
*/
|
||||
public Optional<String> refusal(
|
||||
final long height,
|
||||
final Address author,
|
||||
final Hash commitDigest,
|
||||
final Optional<FalconSeal> seal) {
|
||||
return refusal(height, author, commitDigest, seal, List.of());
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether a Commit may count toward quorum, hybrid certificate included.
|
||||
*
|
||||
* @param height the block height the commit targets
|
||||
* @param author the RECOVERED author of the signed Commit message
|
||||
* @param commitDigest the commit digest every seal must have signed
|
||||
* @param seal the Falcon seal carried in its own slot
|
||||
* @param extraSeals the non-Falcon scheme seals carried alongside it
|
||||
* @return empty when the commit counts; otherwise the refusal, with names and numbers
|
||||
*/
|
||||
public Optional<String> refusal(
|
||||
final long height,
|
||||
final Address author,
|
||||
final Hash commitDigest,
|
||||
final Optional<FalconSeal> seal,
|
||||
final List<SchemeSeal> extraSeals) {
|
||||
final Optional<String> falconVerdict = falconRefusal(height, author, commitDigest, seal);
|
||||
if (falconVerdict.isPresent() || !armedAt(height) || schemeSchedule == null) {
|
||||
return falconVerdict;
|
||||
}
|
||||
return hybridRefusal(height, author, commitDigest, seal.orElseThrow(), extraSeals);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every scheme the schedule names at this height, other than Falcon, must be present and valid.
|
||||
*/
|
||||
private Optional<String> hybridRefusal(
|
||||
final long height,
|
||||
final Address author,
|
||||
final Hash commitDigest,
|
||||
final FalconSeal falconSeal,
|
||||
final List<SchemeSeal> extraSeals) {
|
||||
final Set<String> required = schemeSchedule.schemesAt(height);
|
||||
for (final String schemeId : required) {
|
||||
if (SealSchemes.FALCON_512.id().equals(schemeId)) {
|
||||
continue; // already decided by the Falcon path above
|
||||
}
|
||||
final Optional<SealScheme> scheme = SealSchemes.byId(schemeId);
|
||||
if (scheme.isEmpty()) {
|
||||
return Optional.of(
|
||||
"AERE HIBRID: the schedule requires scheme '" + schemeId + "' at height " + height
|
||||
+ " and this binary does not implement it - refusing rather than ignoring it");
|
||||
}
|
||||
final byte wire = scheme.get().wireId();
|
||||
SchemeSeal found = null;
|
||||
for (final SchemeSeal candidate : extraSeals) {
|
||||
if (candidate.getSchemeWireId() == wire) {
|
||||
found = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (found == null) {
|
||||
return Optional.of(
|
||||
"AERE HIBRID: commit at height " + height + " carries no " + schemeId
|
||||
+ " seal, which the schedule requires - the vote does not count");
|
||||
}
|
||||
// One identity per message: the hybrid seal must speak for the same validator as the Falcon
|
||||
// seal, and that index must be THIS author in the hybrid registry. Neither is assumed.
|
||||
if (found.getValidatorIndex() != falconSeal.getValidatorIndex()) {
|
||||
return Optional.of(
|
||||
"AERE HIBRID: " + schemeId + " seal is index " + found.getValidatorIndex()
|
||||
+ " but the Falcon seal on the same commit is index "
|
||||
+ falconSeal.getValidatorIndex() + " - one commit, one signer");
|
||||
}
|
||||
final Optional<byte[]> bound = hybridRegistry.address(found.getValidatorIndex());
|
||||
if (bound.isEmpty()
|
||||
|| !Address.wrap(Bytes.wrap(bound.get())).equals(author)) {
|
||||
return Optional.of(
|
||||
"AERE HIBRID: index " + found.getValidatorIndex()
|
||||
+ " is not bound to the commit author " + author + " in the hybrid registry");
|
||||
}
|
||||
final Optional<byte[]> publicKey =
|
||||
hybridRegistry.publicKey(found.getValidatorIndex(), schemeId);
|
||||
if (publicKey.isEmpty()) {
|
||||
return Optional.of(
|
||||
"AERE HIBRID: the hybrid registry holds no " + schemeId + " key for index "
|
||||
+ found.getValidatorIndex());
|
||||
}
|
||||
final boolean valid;
|
||||
try {
|
||||
valid =
|
||||
scheme
|
||||
.get()
|
||||
.verifyRaw(
|
||||
publicKey.get(),
|
||||
commitDigest.getBytes().toArray(),
|
||||
found.getSignature().toArray());
|
||||
} catch (final RuntimeException e) {
|
||||
return Optional.of(
|
||||
"AERE HIBRID: " + schemeId + " verification threw at height " + height + ": "
|
||||
+ e.getMessage());
|
||||
}
|
||||
if (!valid) {
|
||||
return Optional.of(
|
||||
"AERE HIBRID: the " + schemeId + " seal of index " + found.getValidatorIndex()
|
||||
+ " does NOT verify over the commit digest at height " + height
|
||||
+ " - the vote does not count");
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
private Optional<String> falconRefusal(
|
||||
final long height,
|
||||
final Address author,
|
||||
final Hash commitDigest,
|
||||
final Optional<FalconSeal> seal) {
|
||||
if (!armedAt(height)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
if (seal == null || seal.isEmpty()) {
|
||||
return Optional.of(
|
||||
"AERE FULL-PQ: commit at height " + height + " carries NO post-quantum seal and "
|
||||
+ "enforcement is armed from " + armedFromBlock + " - the vote does not count");
|
||||
}
|
||||
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: seal index " + fs.getValidatorIndex() + " is bound to "
|
||||
+ bound + " but the commit was authored by " + author
|
||||
+ " - a seal cannot vouch for someone else's vote");
|
||||
}
|
||||
final boolean valid;
|
||||
try {
|
||||
valid =
|
||||
registry.verifyAtOwnHead(
|
||||
height, fs.getValidatorIndex(), commitDigest.getBytes(), 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 commit digest at height " + height
|
||||
+ " - the vote does not count");
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
@ -1,206 +0,0 @@
|
||||
/*
|
||||
* 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 PREPARE. Step 4 of the design note
|
||||
* PREPARE-SI-ROUNDCHANGE-SUB-PQ-PROIECTARE-2026-08-28.
|
||||
*
|
||||
* <p>From the armed height onwards, a PREPARE does not count without a valid post-quantum seal from
|
||||
* its OWN author. The structure copies {@link PqCommitEnforcement} line for line on purpose: a
|
||||
* second rendering of the same idea, written differently, diverges from the first one eventually.
|
||||
*
|
||||
* <p><b>WHY THIS IS A DIFFERENT LAYER FROM COMMIT, AND MORE DANGEROUS.</b> Measured 2026-08-28
|
||||
* (finding D-277): commit enforcement can be bypassed by a single unarmed proposer. It gathers the
|
||||
* commits, forms the block, and the others import it, because block import validates the HEADER,
|
||||
* not the votes. PREPARE does not work that way: an armed node that refuses unsealed PREPAREs never
|
||||
* reaches the "prepared" state, so it never sends COMMIT at all, and the unarmed node alone is not
|
||||
* a quorum. So PREPARE enforcement is STRICTLY STRONGER - and that is exactly why it no longer has
|
||||
* the safety net commit had during an activation. Arm it only after coverage has been measured.
|
||||
*
|
||||
* <p><b>WHAT THE SEAL SIGNS, AND WHY NOT THE SAME THING AS COMMIT.</b> Its own domain,
|
||||
* {@code AERE-PQ-PREPARE-1}, over (chainId, height, ROUND, digest). Under the commit domain, a
|
||||
* PREPARE seal produced HONESTLY could be pasted onto a forged COMMIT and the enforcement there
|
||||
* would accept it. The round is part of the message too: two PREPAREs for the same block in
|
||||
* different rounds are two different assertions, and a seal from a failed round must not justify
|
||||
* another one.
|
||||
*
|
||||
* <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 PqPrepareEnforcement {
|
||||
|
||||
/** The height nothing ever reaches: disarmed. */
|
||||
public static final long DISARMED = Long.MAX_VALUE;
|
||||
|
||||
/** The property that arms PREPARE enforcement. */
|
||||
public static final String PROPERTY_FORK_BLOCK = "aere.pq.preparePq.forkBlock";
|
||||
|
||||
/** The equivalent environment variable. */
|
||||
public static final String ENV_FORK_BLOCK = "AERE_PQ_PREPAREPQ_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 PqPrepareEnforcement(
|
||||
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 PqPrepareEnforcement(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-PREPARE-ENF-01 when the value is present but is not a
|
||||
* non-negative decimal height
|
||||
*/
|
||||
public static PqPrepareEnforcement 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-PREPARE-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 PqPrepareEnforcement(armedFrom, PqSignerRegistry.falconSealSupport());
|
||||
}
|
||||
|
||||
/** Whether enforcement is active at this height. */
|
||||
public boolean armedAt(final long height) {
|
||||
return height >= armedFromBlock;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decides whether a PREPARE may count.
|
||||
*
|
||||
* @param height the height the PREPARE targets (the round's sequence number)
|
||||
* @param round the PREPARE'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 block the PREPARE speaks about
|
||||
* @param seal the post-quantum seal carried by the payload, if any
|
||||
* @return empty when the PREPARE counts; 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: prepare at height " + height + " round " + round
|
||||
+ " carries NO post-quantum seal and enforcement is armed from " + armedFromBlock
|
||||
+ " - the vote does not count");
|
||||
}
|
||||
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: prepare seal index " + fs.getValidatorIndex() + " is bound to " + bound
|
||||
+ " but the prepare was authored by " + author
|
||||
+ " - a seal cannot vouch for someone else's vote");
|
||||
}
|
||||
|
||||
final Bytes32 message;
|
||||
try {
|
||||
message = PqAnchor.prepareMessage(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 prepare 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 prepare message at height " + height + " round " + round
|
||||
+ " - the vote does not count");
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
@ -1,138 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 ConsenSys AG.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
|
||||
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations under the License.
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
package org.hyperledger.besu.consensus.qbft.core.validation;
|
||||
|
||||
import org.hyperledger.besu.consensus.common.bft.ConsensusRoundIdentifier;
|
||||
import org.hyperledger.besu.consensus.common.bft.payload.SignedData;
|
||||
import org.hyperledger.besu.consensus.qbft.core.messagewrappers.Prepare;
|
||||
import org.hyperledger.besu.consensus.qbft.core.payload.PreparePayload;
|
||||
import org.hyperledger.besu.datatypes.Address;
|
||||
import org.hyperledger.besu.datatypes.Hash;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* The Prepare validator.
|
||||
*
|
||||
* <p>AERE (2026-08-29), step 4 of PREPARE-SI-ROUNDCHANGE-SUB-PQ-PROIECTARE-2026-08-28: an OPTIONAL
|
||||
* post-quantum enforcement hook, gated on height (see {@link PqPrepareEnforcement}). 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 {@link CommitValidator}, deliberately: a
|
||||
* second rendering of the same idea, written differently, diverges eventually.
|
||||
*/
|
||||
public class PrepareValidator {
|
||||
|
||||
private static final String ERROR_PREFIX = "Invalid Prepare Message";
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(PrepareValidator.class);
|
||||
|
||||
private final Collection<Address> validators;
|
||||
private final ConsensusRoundIdentifier targetRound;
|
||||
private final Hash expectedDigest;
|
||||
// AERE full-PQ: optional enforcement, gated on height. Null means upstream behaviour, which is
|
||||
// exactly what runs on every node today.
|
||||
private final PqPrepareEnforcement pqEnforcement;
|
||||
|
||||
/**
|
||||
* Instantiates a new Prepare validator, self-wiring the AERE post-quantum enforcement from the
|
||||
* system configuration. Without the arming property the hook is null and nothing changes.
|
||||
*
|
||||
* @param validators the validators
|
||||
* @param targetRound the target round
|
||||
* @param expectedDigest the expected digest
|
||||
*/
|
||||
public PrepareValidator(
|
||||
final Collection<Address> validators,
|
||||
final ConsensusRoundIdentifier targetRound,
|
||||
final Hash expectedDigest) {
|
||||
this(validators, targetRound, expectedDigest, PqPrepareEnforcement.fromSystemConfig());
|
||||
}
|
||||
|
||||
/**
|
||||
* Instantiates a new Prepare validator with optional post-quantum enforcement.
|
||||
*
|
||||
* @param validators the validators
|
||||
* @param targetRound the target round
|
||||
* @param expectedDigest the expected digest
|
||||
* @param pqEnforcement the height-gated enforcement, or null for upstream behaviour
|
||||
*/
|
||||
public PrepareValidator(
|
||||
final Collection<Address> validators,
|
||||
final ConsensusRoundIdentifier targetRound,
|
||||
final Hash expectedDigest,
|
||||
final PqPrepareEnforcement pqEnforcement) {
|
||||
this.validators = validators;
|
||||
this.targetRound = targetRound;
|
||||
this.expectedDigest = expectedDigest;
|
||||
this.pqEnforcement = pqEnforcement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate.
|
||||
*
|
||||
* @param msg the msg
|
||||
* @return the boolean
|
||||
*/
|
||||
public boolean validate(final Prepare msg) {
|
||||
return validate(msg.getSignedPayload());
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate.
|
||||
*
|
||||
* @param signedPayload the signed payload
|
||||
* @return the boolean
|
||||
*/
|
||||
public boolean validate(final SignedData<PreparePayload> signedPayload) {
|
||||
if (!validators.contains(signedPayload.getAuthor())) {
|
||||
LOG.info("{}: did not originate from a recognized validator.", ERROR_PREFIX);
|
||||
return false;
|
||||
}
|
||||
|
||||
final PreparePayload payload = signedPayload.getPayload();
|
||||
|
||||
if (!payload.getRoundIdentifier().equals(targetRound)) {
|
||||
LOG.info("{}: did not target expected round/height", ERROR_PREFIX);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!payload.getDigest().equals(expectedDigest)) {
|
||||
LOG.info("{}: did not contain expected digest", ERROR_PREFIX);
|
||||
return false;
|
||||
}
|
||||
|
||||
// AERE full-PQ: from the armed height on, a PREPARE counts only with a valid post-quantum seal
|
||||
// bound to THIS very author. Below it, or with no enforcement supplied, nothing changes.
|
||||
if (pqEnforcement != null) {
|
||||
final Optional<String> refusal =
|
||||
pqEnforcement.refusal(
|
||||
targetRound.getSequenceNumber(),
|
||||
targetRound.getRoundNumber(),
|
||||
signedPayload.getAuthor(),
|
||||
expectedDigest,
|
||||
payload.getFalconSeal());
|
||||
if (refusal.isPresent()) {
|
||||
LOG.info("{}: {}", ERROR_PREFIX, refusal.get());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@ -1,277 +0,0 @@
|
||||
/* AERE HYBRID, step 1: the hybrid certificate inside the commit message.
|
||||
*
|
||||
* The proof that matters most comes FIRST: the golden vectors. They were measured on the
|
||||
* binary from BEFORE this change (2026-08-24, by printing the encoding of a CommitPayload
|
||||
* built from fixed values) and are copied here to be immovable. As long as they stay green,
|
||||
* a commit without extras encodes exactly as on the live fleet, so the new binary can be
|
||||
* warmed on a real node with no flag day. If anyone ever changes the base encoding, they
|
||||
* turn red before the change can reach the chain.
|
||||
*
|
||||
* The rest proves the hybrid is truly hybrid: REAL Falcon plus REAL SLH-DSA, two unrelated
|
||||
* mathematical families in the same message, each verified with its own scheme. */
|
||||
package org.hyperledger.besu.consensus.qbft.core.payload;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import org.hyperledger.besu.consensus.common.bft.ConsensusRoundIdentifier;
|
||||
import org.hyperledger.besu.consensus.common.bft.FalconSeal;
|
||||
import org.hyperledger.besu.consensus.common.bft.PqAnchorV2;
|
||||
import org.hyperledger.besu.consensus.common.bft.SchemeSeal;
|
||||
import org.hyperledger.besu.consensus.common.bft.SealScheme;
|
||||
import org.hyperledger.besu.consensus.common.bft.SealSchemes;
|
||||
import org.hyperledger.besu.crypto.SECPSignature;
|
||||
import org.hyperledger.besu.crypto.SecureRandomProvider;
|
||||
import org.hyperledger.besu.crypto.SignatureAlgorithmFactory;
|
||||
import org.hyperledger.besu.datatypes.Hash;
|
||||
import org.hyperledger.besu.ethereum.rlp.BytesValueRLPInput;
|
||||
import org.hyperledger.besu.ethereum.rlp.BytesValueRLPOutput;
|
||||
import org.hyperledger.besu.ethereum.rlp.RLPException;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.apache.tuweni.bytes.Bytes;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
public class CommitPayloadHybridTest {
|
||||
|
||||
// ---- VECTORII DE AUR, masurati pe binarul de DINAINTE de aceasta schimbare -------------------
|
||||
private static final String AUR_FARA_PQ =
|
||||
"0xf8660703a0000000000000000000000000000000000000000000000000000000000000002a"
|
||||
+ "b8410101010101010101010101010101010101010101010101010101010101010101"
|
||||
+ "020202020202020202020202020202020202020202020202020202020202020200";
|
||||
private static final String AUR_CU_FALCON =
|
||||
"0xf86d0703a0000000000000000000000000000000000000000000000000000000000000002a"
|
||||
+ "b8410101010101010101010101010101010101010101010101010101010101010101"
|
||||
+ "020202020202020202020202020202020202020202020202020202020202020200"
|
||||
+ "c60384deadbeef";
|
||||
private static final String AUR_HASH_FARA_PQ =
|
||||
"0xe4e36f241e03352338d89a5a0c98a59c5595d034f5a5d10e2316239ee0aecc40";
|
||||
private static final String AUR_HASH_CU_FALCON =
|
||||
"0x03e71e08f2b72cde0e493edfce8e26529a34b92c3c0daf0ab3591a175aec4b13";
|
||||
|
||||
private static final ConsensusRoundIdentifier ROUND = new ConsensusRoundIdentifier(7L, 3);
|
||||
private static final Hash DIGEST = Hash.fromHexStringLenient("0x2a");
|
||||
private static final FalconSeal FALCON_AUR =
|
||||
new FalconSeal(3, Bytes.fromHexString("0xdeadbeef"));
|
||||
|
||||
private final SecureRandom random = SecureRandomProvider.createSecureRandom();
|
||||
|
||||
private static SECPSignature ecdsa() {
|
||||
return SignatureAlgorithmFactory.getInstance()
|
||||
.decodeSignature(
|
||||
Bytes.fromHexString(
|
||||
"0x"
|
||||
+ "0101010101010101010101010101010101010101010101010101010101010101"
|
||||
+ "0202020202020202020202020202020202020202020202020202020202020202"
|
||||
+ "00"));
|
||||
}
|
||||
|
||||
private static CommitPayload prinCodec(final CommitPayload original) {
|
||||
final BytesValueRLPOutput out = new BytesValueRLPOutput();
|
||||
original.writeTo(out);
|
||||
return CommitPayload.readFrom(new BytesValueRLPInput(out.encoded(), false));
|
||||
}
|
||||
|
||||
// ============================================================ 1. LACATUL: flota vie neatinsa
|
||||
|
||||
@Test
|
||||
public void aCommitWithoutPqEncodesExactlyAsTheLiveFleetDoes() {
|
||||
final CommitPayload p = new CommitPayload(ROUND, DIGEST, ecdsa());
|
||||
assertThat(p.encoded().toHexString()).isEqualTo(AUR_FARA_PQ);
|
||||
assertThat(p.hashForSignature().toHexString()).isEqualTo(AUR_HASH_FARA_PQ);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aFalconOnlyCommitEncodesExactlyAsTheLiveFleetDoes() {
|
||||
final CommitPayload p =
|
||||
new CommitPayload(ROUND, DIGEST, ecdsa(), Optional.of(FALCON_AUR));
|
||||
assertThat(p.encoded().toHexString()).isEqualTo(AUR_CU_FALCON);
|
||||
assertThat(p.hashForSignature().toHexString()).isEqualTo(AUR_HASH_CU_FALCON);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void theGoldenBytesOfTheLiveFleetStillDecode() {
|
||||
final CommitPayload p =
|
||||
CommitPayload.readFrom(
|
||||
new BytesValueRLPInput(Bytes.fromHexString(AUR_CU_FALCON), false));
|
||||
assertThat(p.getFalconSeal()).contains(FALCON_AUR);
|
||||
assertThat(p.getExtraSeals()).isEmpty();
|
||||
}
|
||||
|
||||
// ============================================================ 2. hibridul, cu crypto REALA
|
||||
|
||||
@Test
|
||||
public void aRealHybridCertificateSurvivesTheRoundTrip() {
|
||||
final SealScheme.GeneratedPair slh = SealSchemes.SLH_DSA_128S.generate(random);
|
||||
final byte[] sig =
|
||||
SealSchemes.SLH_DSA_128S.sign(slh.privateKey(), DIGEST.getBytes().toArray()).orElseThrow();
|
||||
final SchemeSeal extra =
|
||||
new SchemeSeal(SealSchemes.SLH_DSA_128S.wireId(), 3, Bytes.wrap(sig));
|
||||
|
||||
final CommitPayload original =
|
||||
new CommitPayload(ROUND, DIGEST, ecdsa(), Optional.of(FALCON_AUR), List.of(extra));
|
||||
final CommitPayload back = prinCodec(original);
|
||||
|
||||
assertThat(back).isEqualTo(original);
|
||||
assertThat(back.getFalconSeal()).contains(FALCON_AUR);
|
||||
assertThat(back.getExtraSeals()).hasSize(1);
|
||||
// si semnatura chiar se verifica dupa drumul prin codec, cu SCHEMA ei
|
||||
assertThat(
|
||||
SealSchemes.SLH_DSA_128S.verifyRaw(
|
||||
slh.publicRegistryForm(),
|
||||
DIGEST.getBytes().toArray(),
|
||||
back.getExtraSeals().get(0).getSignature().toArray()))
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void twoUnrelatedFamiliesTravelInOneCommitAndEachVerifiesWithItsOwn() {
|
||||
final SealScheme.GeneratedPair falcon = SealSchemes.FALCON_512.generate(random);
|
||||
final SealScheme.GeneratedPair slh = SealSchemes.SLH_DSA_128S.generate(random);
|
||||
final byte[] message = DIGEST.getBytes().toArray();
|
||||
final byte[] sigFalcon = SealSchemes.FALCON_512.sign(falcon.privateKey(), message).orElseThrow();
|
||||
final byte[] sigSlh = SealSchemes.SLH_DSA_128S.sign(slh.privateKey(), message).orElseThrow();
|
||||
|
||||
final CommitPayload p =
|
||||
new CommitPayload(
|
||||
ROUND,
|
||||
DIGEST,
|
||||
ecdsa(),
|
||||
Optional.of(new FalconSeal(3, Bytes.wrap(sigFalcon))),
|
||||
List.of(new SchemeSeal(SealSchemes.SLH_DSA_128S.wireId(), 3, Bytes.wrap(sigSlh))));
|
||||
final CommitPayload back = prinCodec(p);
|
||||
|
||||
assertThat(
|
||||
SealSchemes.FALCON_512.verifyRaw(
|
||||
falcon.publicRegistryForm(),
|
||||
message,
|
||||
back.getFalconSeal().orElseThrow().getSignature().toArray()))
|
||||
.isTrue();
|
||||
assertThat(
|
||||
SealSchemes.SLH_DSA_128S.verifyRaw(
|
||||
slh.publicRegistryForm(),
|
||||
message,
|
||||
back.getExtraSeals().get(0).getSignature().toArray()))
|
||||
.isTrue();
|
||||
// the CROSSED CONTROL: each signature is refused by the OTHER scheme, so the hybrid
|
||||
// really stands on two legs and not on the same leg twice
|
||||
assertThat(SealSchemes.SLH_DSA_128S.verifyRaw(slh.publicRegistryForm(), message, sigFalcon))
|
||||
.isFalse();
|
||||
assertThat(SealSchemes.FALCON_512.verifyRaw(falcon.publicRegistryForm(), message, sigSlh))
|
||||
.isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void theEcdsaSignedBytesCoverTheExtras() {
|
||||
final SchemeSeal extra =
|
||||
new SchemeSeal(SealSchemes.SLH_DSA_128S.wireId(), 3, Bytes.fromHexString("0xabcdef"));
|
||||
final CommitPayload faraExtras =
|
||||
new CommitPayload(ROUND, DIGEST, ecdsa(), Optional.of(FALCON_AUR));
|
||||
final CommitPayload cuExtras =
|
||||
new CommitPayload(ROUND, DIGEST, ecdsa(), Optional.of(FALCON_AUR), List.of(extra));
|
||||
// if the hash were the same, extras could be added or removed by anyone without
|
||||
// breaking the author's signature
|
||||
assertThat(cuExtras.hashForSignature()).isNotEqualTo(faraExtras.hashForSignature());
|
||||
}
|
||||
|
||||
// ============================================================ 3. refuzurile
|
||||
|
||||
@Test
|
||||
public void extrasWithoutAFalconSealAreRefusedAtConstruction() {
|
||||
final SchemeSeal extra =
|
||||
new SchemeSeal(SealSchemes.SLH_DSA_128S.wireId(), 3, Bytes.fromHexString("0xabcdef"));
|
||||
assertThatThrownBy(
|
||||
() -> new CommitPayload(ROUND, DIGEST, ecdsa(), Optional.empty(), List.of(extra)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("require the Falcon seal");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void falconInTheExtrasIsRefusedSoOneSignatureHasOneHome() {
|
||||
final SchemeSeal falconInExtras =
|
||||
new SchemeSeal(SealSchemes.FALCON_512.wireId(), 3, Bytes.fromHexString("0xabcdef"));
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new CommitPayload(
|
||||
ROUND, DIGEST, ecdsa(), Optional.of(FALCON_AUR), List.of(falconInExtras)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("own slot");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void anEmptyExtrasElementOnTheWireIsRefused() {
|
||||
// doua codificari ale aceleiasi valori nu au voie sa existe: extras gol == extras absent
|
||||
final BytesValueRLPOutput out = new BytesValueRLPOutput();
|
||||
out.startList();
|
||||
out.writeLongScalar(ROUND.getSequenceNumber());
|
||||
out.writeIntScalar(ROUND.getRoundNumber());
|
||||
out.writeBytes(DIGEST.getBytes());
|
||||
out.writeBytes(ecdsa().encodedBytes());
|
||||
out.startList();
|
||||
out.writeIntScalar(FALCON_AUR.getValidatorIndex());
|
||||
out.writeBytes(FALCON_AUR.getSignature());
|
||||
out.endList();
|
||||
out.writeRaw(PqAnchorV2.encode(List.of()));
|
||||
out.endList();
|
||||
|
||||
assertThatThrownBy(
|
||||
() -> CommitPayload.readFrom(new BytesValueRLPInput(out.encoded(), false)))
|
||||
.isInstanceOf(RLPException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aThirdTrailingElementIsRefusedByCanonicality() {
|
||||
final BytesValueRLPOutput out = new BytesValueRLPOutput();
|
||||
out.startList();
|
||||
out.writeLongScalar(ROUND.getSequenceNumber());
|
||||
out.writeIntScalar(ROUND.getRoundNumber());
|
||||
out.writeBytes(DIGEST.getBytes());
|
||||
out.writeBytes(ecdsa().encodedBytes());
|
||||
out.startList();
|
||||
out.writeIntScalar(FALCON_AUR.getValidatorIndex());
|
||||
out.writeBytes(FALCON_AUR.getSignature());
|
||||
out.endList();
|
||||
out.writeRaw(
|
||||
PqAnchorV2.encode(
|
||||
List.of(
|
||||
new SchemeSeal(
|
||||
SealSchemes.SLH_DSA_128S.wireId(), 3, Bytes.fromHexString("0xabcdef")))));
|
||||
out.writeBytes(Bytes.fromHexString("0x99")); // al treilea element, nu exista in format
|
||||
out.endList();
|
||||
|
||||
assertThatThrownBy(
|
||||
() -> CommitPayload.readFrom(new BytesValueRLPInput(out.encoded(), false)))
|
||||
.isInstanceOf(RLPException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aCorruptedExtrasElementIsAnRlpFailureNotACrash() {
|
||||
final BytesValueRLPOutput out = new BytesValueRLPOutput();
|
||||
out.startList();
|
||||
out.writeLongScalar(ROUND.getSequenceNumber());
|
||||
out.writeIntScalar(ROUND.getRoundNumber());
|
||||
out.writeBytes(DIGEST.getBytes());
|
||||
out.writeBytes(ecdsa().encodedBytes());
|
||||
out.startList();
|
||||
out.writeIntScalar(FALCON_AUR.getValidatorIndex());
|
||||
out.writeBytes(FALCON_AUR.getSignature());
|
||||
out.endList();
|
||||
// un element care NU e un certificat v2: versiune necunoscuta
|
||||
final BytesValueRLPOutput bad = new BytesValueRLPOutput();
|
||||
bad.startList();
|
||||
bad.writeIntScalar(99);
|
||||
bad.startList();
|
||||
bad.endList();
|
||||
bad.endList();
|
||||
out.writeRaw(bad.encoded());
|
||||
out.endList();
|
||||
|
||||
assertThatThrownBy(
|
||||
() -> CommitPayload.readFrom(new BytesValueRLPInput(out.encoded(), false)))
|
||||
.isInstanceOf(RLPException.class)
|
||||
.hasMessageContaining("AERE HIBRID");
|
||||
}
|
||||
}
|
||||
@ -1,209 +0,0 @@
|
||||
/*
|
||||
* Copyright contributors to Besu.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
|
||||
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations under the License.
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
package org.hyperledger.besu.consensus.qbft.core.payload;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import org.hyperledger.besu.consensus.common.bft.ConsensusRoundIdentifier;
|
||||
import org.hyperledger.besu.consensus.common.bft.FalconSeal;
|
||||
import org.hyperledger.besu.consensus.common.bft.PqAnchor;
|
||||
import org.hyperledger.besu.datatypes.Hash;
|
||||
import org.hyperledger.besu.ethereum.rlp.BytesValueRLPInput;
|
||||
import org.hyperledger.besu.ethereum.rlp.BytesValueRLPOutput;
|
||||
import org.hyperledger.besu.ethereum.rlp.RLPException;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import org.apache.tuweni.bytes.Bytes;
|
||||
import org.apache.tuweni.bytes.Bytes32;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* STEP 1 of PREPARE-SI-ROUNDCHANGE-SUB-PQ-PROIECTARE-2026-08-28: the wire can carry a
|
||||
* post-quantum seal on a PREPARE, and NOTHING emits one yet.
|
||||
*
|
||||
* <p>The test that matters most is the first one: a PREPARE without a seal encodes EXACTLY as it
|
||||
* did before this change. Without it the new binary cannot be rolled onto a live fleet, because the
|
||||
* neighbours would compute a different signature hash and reject every PREPARE. The golden vector
|
||||
* is built from the canonical encoding of the OLD form, not from a run of the new code.
|
||||
*/
|
||||
public class PreparePayloadPqTest {
|
||||
|
||||
private static final ConsensusRoundIdentifier ROUND = new ConsensusRoundIdentifier(7, 3);
|
||||
private static final Hash DIGEST =
|
||||
Hash.wrap(
|
||||
Bytes32.fromHexString(
|
||||
"0x000000000000000000000000000000000000000000000000000000000000002a"));
|
||||
|
||||
/**
|
||||
* THE GOLDEN VECTOR of the old form: RLP[ sequence, round, digest(32) ]. Built here from its
|
||||
* elements, not copied from a run, so that what it is made of stays visible.
|
||||
*
|
||||
* <p>The first version of this test wrapped sequence and round in a LIST, and it failed. The
|
||||
* source (QbftPayload.writeConsensusRound) writes them as two FLAT scalars. The test was the
|
||||
* wrong one, not the code - and that is worth saying, because a golden vector written from
|
||||
* intuition instead of from the source would have either refused good code or, worse, been
|
||||
* "fixed" by moving the code to match it.
|
||||
*/
|
||||
private static Bytes goldenOldForm() {
|
||||
final BytesValueRLPOutput out = new BytesValueRLPOutput();
|
||||
out.startList();
|
||||
out.writeLongScalar(7L);
|
||||
out.writeIntScalar(3);
|
||||
out.writeBytes(DIGEST.getBytes());
|
||||
out.endList();
|
||||
return out.encoded();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aPrepareWithoutASealEncodesEXACTLYAsBefore() {
|
||||
final PreparePayload p = new PreparePayload(ROUND, DIGEST);
|
||||
assertThat(p.encoded()).isEqualTo(goldenOldForm());
|
||||
// and the signature hash, which is precisely what binds the author to the message
|
||||
assertThat(p.hashForSignature())
|
||||
.isEqualTo(new PreparePayload(ROUND, DIGEST, Optional.empty()).hashForSignature());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aPrepareWithoutASealReadsBackIdentical() {
|
||||
final Bytes encoded = new PreparePayload(ROUND, DIGEST).encoded();
|
||||
final PreparePayload decoded = PreparePayload.readFrom(new BytesValueRLPInput(encoded, false));
|
||||
assertThat(decoded.getFalconSeal()).isEmpty();
|
||||
assertThat(decoded.getDigest()).isEqualTo(DIGEST);
|
||||
assertThat(decoded.getRoundIdentifier()).isEqualTo(ROUND);
|
||||
assertThat(decoded.encoded()).isEqualTo(encoded);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aPrepareWithASealReadsBackIdentical() {
|
||||
final FalconSeal seal = new FalconSeal(4, Bytes.fromHexString("0xdeadbeef"));
|
||||
final PreparePayload p = new PreparePayload(ROUND, DIGEST, 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(goldenOldForm().size());
|
||||
|
||||
final PreparePayload decoded = PreparePayload.readFrom(new BytesValueRLPInput(encoded, false));
|
||||
assertThat(decoded.getFalconSeal()).isPresent();
|
||||
assertThat(decoded.getFalconSeal().get().getValidatorIndex()).isEqualTo(4);
|
||||
assertThat(decoded.getFalconSeal().get().getSignature()).isEqualTo(Bytes.fromHexString("0xdeadbeef"));
|
||||
assertThat(decoded).isEqualTo(p);
|
||||
assertThat(decoded.encoded()).isEqualTo(encoded);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aSealChangesTheSignatureHash() {
|
||||
// If it did not change it, the author's ECDSA signature would not cover the seal, and anyone
|
||||
// could paste a foreign index onto an otherwise valid PREPARE.
|
||||
final PreparePayload without = new PreparePayload(ROUND, DIGEST);
|
||||
final PreparePayload with =
|
||||
new PreparePayload(ROUND, DIGEST, Optional.of(new FalconSeal(4, Bytes.fromHexString("0xdeadbeef"))));
|
||||
assertThat(with.hashForSignature()).isNotEqualTo(without.hashForSignature());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aNONCANONICALEncodingIsRefused() {
|
||||
// A third element that is not a seal: the decoder could ignore it, and then two different byte
|
||||
// strings would authenticate to the same validator. It is refused.
|
||||
final BytesValueRLPOutput out = new BytesValueRLPOutput();
|
||||
out.startList();
|
||||
out.writeLongScalar(7L);
|
||||
out.writeIntScalar(3);
|
||||
out.writeBytes(DIGEST.getBytes());
|
||||
out.startList();
|
||||
out.writeIntScalar(4);
|
||||
out.writeBytes(Bytes.fromHexString("0xdeadbeef"));
|
||||
out.writeBytes(Bytes.fromHexString("0xff")); // element in plus INAUNTRUL sigiliului
|
||||
out.endList();
|
||||
out.endList();
|
||||
|
||||
assertThatThrownBy(() -> PreparePayload.readFrom(new BytesValueRLPInput(out.encoded(), false)))
|
||||
.isInstanceOf(RLPException.class);
|
||||
}
|
||||
|
||||
// ---- domain separation: the security part of the design -------------------------------------
|
||||
|
||||
@Test
|
||||
public void thePREPAREMessageIsNotTheCOMMITMessage() {
|
||||
// If it were the same, a PREPARE seal given HONESTLY could be pasted onto a forged COMMIT and
|
||||
// the enforcement there would accept it. That is exactly the attack this separation closes.
|
||||
final Bytes32 prep = PqAnchor.prepareMessage(2800L, 100L, 3, DIGEST.getBytes());
|
||||
final Bytes32 comm = PqAnchor.commitMessage(2800L, 100L, DIGEST.getBytes());
|
||||
assertThat(prep).isNotEqualTo(comm);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void thePREPAREMessageDependsOnTheROUND() {
|
||||
// Two PREPAREs for the same block in different rounds are two different assertions. Without the
|
||||
// round in the preimage, a seal from a failed round would justify another one.
|
||||
final Bytes32 r3 = PqAnchor.prepareMessage(2800L, 100L, 3, DIGEST.getBytes());
|
||||
final Bytes32 r4 = PqAnchor.prepareMessage(2800L, 100L, 4, DIGEST.getBytes());
|
||||
assertThat(r3).isNotEqualTo(r4);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void thePREPAREMessageDependsOnCHAINAndHEIGHT() {
|
||||
final Bytes32 baza = PqAnchor.prepareMessage(2800L, 100L, 3, DIGEST.getBytes());
|
||||
assertThat(PqAnchor.prepareMessage(2801L, 100L, 3, DIGEST.getBytes())).isNotEqualTo(baza);
|
||||
assertThat(PqAnchor.prepareMessage(2800L, 101L, 3, DIGEST.getBytes())).isNotEqualTo(baza);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void thePREPAREMessageRefusesImpossibleInputs() {
|
||||
assertThatThrownBy(() -> PqAnchor.prepareMessage(2800L, -1L, 3, DIGEST.getBytes()))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> PqAnchor.prepareMessage(2800L, 100L, -1, DIGEST.getBytes()))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> PqAnchor.prepareMessage(2800L, 100L, 3, Bytes.fromHexString("0x00")))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
// ===============================================================================================
|
||||
// THE BRIDGE TO CLIENT 2, added 2026-08-29 (finding D-282).
|
||||
//
|
||||
// The two strings below are written LITERALLY in client 2's test as well
|
||||
// (AereQbftPrepareSealWireProofTests), for the same values. This is not a round trip: each
|
||||
// implementation encodes the payload on its own and compares it with THE SAME string. If either
|
||||
// one moves, one of the two tests fails - and that is exactly the question that matters, because
|
||||
// a client-2 decoder strict at three elements would have rejected every PREPARE of a fleet with
|
||||
// emission armed, exactly as its decoder strict at four rejected every commit at an anchor
|
||||
// height.
|
||||
//
|
||||
// And so that this is not two implementations being wrong in the same way, the bytes were checked
|
||||
// with a THIRD RLP encoder as well, written separately in python, with no connection to either
|
||||
// project: both strings matched exactly.
|
||||
//
|
||||
// The structure, so it can be read by eye:
|
||||
// e3 | 07 | 03 | a0 <32 digest bytes> = old form, three elements
|
||||
// ea | 07 | 03 | a0 <32 digest bytes> | c6 04 84 deadbeef = with a seal, four elements
|
||||
// ===============================================================================================
|
||||
|
||||
private static final String AUR_FARA_SIGILIU =
|
||||
"0xe30703a0000000000000000000000000000000000000000000000000000000000000002a";
|
||||
private static final String AUR_CU_SIGILIU =
|
||||
"0xea0703a0000000000000000000000000000000000000000000000000000000000000002ac60484deadbeef";
|
||||
|
||||
@Test
|
||||
public void theWireBytesAreTHESAMEAsInClient2sTest() {
|
||||
assertThat(new PreparePayload(ROUND, DIGEST).encoded())
|
||||
.isEqualTo(Bytes.fromHexString(AUR_FARA_SIGILIU));
|
||||
|
||||
final PreparePayload with =
|
||||
new PreparePayload(
|
||||
ROUND, DIGEST, Optional.of(new FalconSeal(4, Bytes.fromHexString("0xdeadbeef"))));
|
||||
assertThat(with.encoded()).isEqualTo(Bytes.fromHexString(AUR_CU_SIGILIU));
|
||||
}
|
||||
}
|
||||
@ -1,195 +0,0 @@
|
||||
/*
|
||||
* 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.statemachine;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import org.hyperledger.besu.consensus.common.bft.ConsensusRoundIdentifier;
|
||||
import org.hyperledger.besu.consensus.common.bft.FalconSeal;
|
||||
import org.hyperledger.besu.consensus.common.bft.MessageTracker;
|
||||
import org.hyperledger.besu.consensus.common.bft.PqSealCache;
|
||||
import org.hyperledger.besu.consensus.common.bft.statemachine.FutureMessageBuffer;
|
||||
import org.hyperledger.besu.consensus.qbft.core.QbftMessageFixture;
|
||||
import org.hyperledger.besu.consensus.qbft.core.QbftReceivedMessageEventFixture;
|
||||
import org.hyperledger.besu.consensus.qbft.core.messagedata.CommitMessageData;
|
||||
import org.hyperledger.besu.consensus.qbft.core.messagedata.QbftV1;
|
||||
import org.hyperledger.besu.consensus.qbft.core.messagewrappers.Commit;
|
||||
import org.hyperledger.besu.consensus.qbft.core.types.QbftBlockCodec;
|
||||
import org.hyperledger.besu.consensus.qbft.core.types.QbftBlockHeader;
|
||||
import org.hyperledger.besu.consensus.qbft.core.types.QbftBlockchain;
|
||||
import org.hyperledger.besu.consensus.qbft.core.types.QbftFinalState;
|
||||
import org.hyperledger.besu.consensus.qbft.core.types.QbftGossiper;
|
||||
import org.hyperledger.besu.consensus.qbft.core.types.QbftMessage;
|
||||
import org.hyperledger.besu.datatypes.Address;
|
||||
import org.hyperledger.besu.datatypes.Hash;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import com.google.common.collect.ImmutableList;
|
||||
import org.apache.tuweni.bytes.Bytes;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
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;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
|
||||
/**
|
||||
* AERE D-227 (2026-08-14): a Commit that arrives AFTER its block was imported is discarded by the
|
||||
* height gate in {@link QbftController#consumeMessage}, and before this patch its Falcon seal died
|
||||
* with it. Measured on chain 2800: the block imports on the quorum-th Commit, the slowest
|
||||
* validators' Commits consistently arrive after that moment, and their seals appeared in 3% and
|
||||
* 14% of other proposers' certificates while appearing in 100% of their own.
|
||||
*
|
||||
* <p>Every claim here has its pair: the one case that salvages, and the five refusals around it.
|
||||
* The refusals are not decoration - each one guards a real path (an older seal nobody can ask for
|
||||
* again, a fork sibling's seal, a non-validator author, a seal-less commit, and the message itself
|
||||
* staying dead).
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
public class PqLateSealSalvageTest {
|
||||
|
||||
@Mock private QbftBlockchain blockChain;
|
||||
@Mock private QbftFinalState qbftFinalState;
|
||||
@Mock private QbftBlockHeightManagerFactory blockHeightManagerFactory;
|
||||
@Mock private QbftBlockHeader chainHeadBlockHeader;
|
||||
@Mock private BaseQbftBlockHeightManager blockHeightManager;
|
||||
@Mock private Commit commit;
|
||||
@Mock private CommitMessageData commitMessageData;
|
||||
@Mock private MessageTracker messageTracker;
|
||||
@Mock private FutureMessageBuffer<QbftMessage> futureMessageBuffer;
|
||||
@Mock private QbftGossiper qbftGossiper;
|
||||
@Mock private QbftBlockCodec blockEncoder;
|
||||
|
||||
private static final long HEAD = 3L;
|
||||
private static final Hash HEAD_HASH = Hash.hash(Bytes.fromHexString("0xaa"));
|
||||
private static final Hash OTHER_HASH = Hash.hash(Bytes.fromHexString("0xbb"));
|
||||
private final Address validator = Address.fromHexString("0x1");
|
||||
private final Address nonValidator = Address.fromHexString("0x2");
|
||||
private final FalconSeal seal = new FalconSeal(4, Bytes.fromHexString("0x29aabbcc"));
|
||||
|
||||
private QbftController qbftController;
|
||||
|
||||
@BeforeEach
|
||||
public void setup() {
|
||||
PqSealCache.instance().clear();
|
||||
when(blockChain.getChainHeadHeader()).thenReturn(chainHeadBlockHeader);
|
||||
when(blockChain.getChainHeadBlockNumber()).thenReturn(HEAD);
|
||||
when(blockHeightManagerFactory.create(any())).thenReturn(blockHeightManager);
|
||||
when(qbftFinalState.getValidators()).thenReturn(ImmutableList.of(validator));
|
||||
when(chainHeadBlockHeader.getNumber()).thenReturn(HEAD);
|
||||
when(chainHeadBlockHeader.getHash()).thenReturn(HEAD_HASH);
|
||||
when(blockHeightManager.getParentBlockHeader()).thenReturn(chainHeadBlockHeader);
|
||||
when(blockHeightManager.getChainHeight()).thenReturn(HEAD + 1);
|
||||
when(qbftFinalState.isLocalNodeValidator()).thenReturn(true);
|
||||
when(messageTracker.hasSeenMessage(any())).thenReturn(false);
|
||||
qbftController =
|
||||
new QbftController(
|
||||
blockChain,
|
||||
qbftFinalState,
|
||||
blockHeightManagerFactory,
|
||||
qbftGossiper,
|
||||
messageTracker,
|
||||
futureMessageBuffer,
|
||||
blockEncoder);
|
||||
qbftController.start();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
public void cleanup() {
|
||||
// The cache is a singleton: a seal left behind would leak into unrelated tests and buy them
|
||||
// an unearned green.
|
||||
PqSealCache.instance().clear();
|
||||
}
|
||||
|
||||
private void deliverCommit(
|
||||
final long height, final Hash digest, final Address author, final Optional<FalconSeal> fs) {
|
||||
when(commit.getAuthor()).thenReturn(author);
|
||||
when(commit.getRoundIdentifier()).thenReturn(new ConsensusRoundIdentifier(height, 0));
|
||||
when(commit.getDigest()).thenReturn(digest);
|
||||
when(commit.getFalconSeal()).thenReturn(fs);
|
||||
when(commitMessageData.getCode()).thenReturn(QbftV1.COMMIT);
|
||||
when(commitMessageData.decode()).thenReturn(commit);
|
||||
qbftController.handleMessageEvent(
|
||||
new QbftReceivedMessageEventFixture(new QbftMessageFixture(commitMessageData)));
|
||||
}
|
||||
|
||||
private List<FalconSeal> cached() {
|
||||
return PqSealCache.instance().sealsFor(HEAD, HEAD_HASH);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void lateCommitSealForImportedHeadIsSalvaged() {
|
||||
deliverCommit(HEAD, HEAD_HASH, validator, Optional.of(seal));
|
||||
assertThat(cached()).containsExactly(seal);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void salvagedMessageStillDies() {
|
||||
// The pair of the test above, on the same delivery: only the seal survives. Resurrecting the
|
||||
// message would reopen the very height gate the upstream code closed on purpose.
|
||||
deliverCommit(HEAD, HEAD_HASH, validator, Optional.of(seal));
|
||||
verify(blockHeightManager, never()).handleCommitPayload(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sealOlderThanHeadIsNotSalvaged() {
|
||||
// The proposer of block HEAD+1 carries a certificate over HEAD. A seal for HEAD-1 can never
|
||||
// be asked for again; keeping it would only grow the cache.
|
||||
deliverCommit(HEAD - 1, HEAD_HASH, validator, Optional.of(seal));
|
||||
assertThat(cached()).isEmpty();
|
||||
assertThat(PqSealCache.instance().entryCount()).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sealOverDifferentBlockAtHeadHeightIsNotSalvaged() {
|
||||
// A losing round or a fork sibling: same height, different digest. Its seal is over a block
|
||||
// hash the fleet did not import, so carrying it would fail verification anyway - refusing it
|
||||
// here keeps the cache honest instead of relying on the later check.
|
||||
deliverCommit(HEAD, OTHER_HASH, validator, Optional.of(seal));
|
||||
assertThat(cached()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sealFromNonValidatorIsNotSalvaged() {
|
||||
// Without this refusal any peer could write into the cache of every node it is connected to.
|
||||
deliverCommit(HEAD, HEAD_HASH, nonValidator, Optional.of(seal));
|
||||
assertThat(cached()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void commitWithoutSealChangesNothing() {
|
||||
deliverCommit(HEAD, HEAD_HASH, validator, Optional.empty());
|
||||
assertThat(cached()).isEmpty();
|
||||
assertThat(PqSealCache.instance().entryCount()).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void currentHeightCommitIsUntouchedByTheSalvagePath() {
|
||||
// CONTROL: a commit for the CURRENT height (head+1) must take the normal path - handled,
|
||||
// not salvaged. If this fails, the patch moved the gate instead of adding a side-exit.
|
||||
deliverCommit(HEAD + 1, HEAD_HASH, validator, Optional.of(seal));
|
||||
verify(blockHeightManager).handleCommitPayload(commit);
|
||||
assertThat(cached()).isEmpty();
|
||||
}
|
||||
}
|
||||
@ -1,152 +0,0 @@
|
||||
/* AERE full-PQ, the WIRING step: enforcement bound into CommitValidator itself.
|
||||
*
|
||||
* What each case proves:
|
||||
* - disarmed (the old constructor) = upstream behaviour, untouched -- the baseline control;
|
||||
* - armed + commit WITHOUT a PQ seal = the vote does NOT count;
|
||||
* - armed + a REAL Falcon seal over the commit digest = the vote counts;
|
||||
* - armed + another validator's seal (index bound to another address) = refused;
|
||||
* - armed + the same message below the arming height = passes (the gate is the height itself).
|
||||
*
|
||||
* Mesajele sunt semnate ECDSA cu uneltele de amonte (QbftNodeList/MessageFactory), sigiliile
|
||||
* sunt Falcon-512 REAL prin stratul de scheme; nimic mockuit pe drumul criptografic. */
|
||||
package org.hyperledger.besu.consensus.qbft.core.validation;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import org.hyperledger.besu.consensus.common.bft.ConsensusRoundIdentifier;
|
||||
import org.hyperledger.besu.consensus.common.bft.FalconSeal;
|
||||
import org.hyperledger.besu.consensus.common.bft.PqSignerRegistry;
|
||||
import org.hyperledger.besu.consensus.common.bft.SealScheme;
|
||||
import org.hyperledger.besu.consensus.common.bft.SealSchemes;
|
||||
import org.hyperledger.besu.consensus.qbft.core.messagewrappers.Commit;
|
||||
import org.hyperledger.besu.consensus.qbft.core.types.QbftBlockCodec;
|
||||
import org.hyperledger.besu.crypto.SECPSignature;
|
||||
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.HashMap;
|
||||
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.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
public class CommitValidatorPqEnforcementTest {
|
||||
|
||||
private static final int VALIDATOR_COUNT = 3;
|
||||
private static final long HEIGHT = 5_000L;
|
||||
|
||||
private final ConsensusRoundIdentifier round = new ConsensusRoundIdentifier(HEIGHT, 0);
|
||||
private final Hash expectedHash = Hash.fromHexStringLenient("0x1");
|
||||
private QbftNodeList validators;
|
||||
private @Mock QbftBlockCodec qbftBlockCodec;
|
||||
|
||||
private final SecureRandom random = SecureRandomProvider.createSecureRandom();
|
||||
private final Map<Integer, Address> bindings = new HashMap<>();
|
||||
private final Map<Integer, byte[]> cheiPublice = new HashMap<>();
|
||||
private final Map<Integer, SealScheme.PrivateHandle> cheiPrivate = new HashMap<>();
|
||||
|
||||
/** Registru de test cu legaturi index->adresa si verificare prin schema REALA. */
|
||||
private final PqSignerRegistry registry =
|
||||
new PqSignerRegistry() {
|
||||
@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 = cheiPublice.get(validatorIndex);
|
||||
return pk != null
|
||||
&& SealSchemes.FALCON_512.verifyRaw(pk, message.toArray(), signature.toArray());
|
||||
}
|
||||
};
|
||||
|
||||
@BeforeEach
|
||||
public void setup() {
|
||||
validators = QbftNodeList.createNodes(VALIDATOR_COUNT, qbftBlockCodec);
|
||||
for (int i = 0; i < VALIDATOR_COUNT; i++) {
|
||||
final SealScheme.GeneratedPair pereche = SealSchemes.FALCON_512.generate(random);
|
||||
bindings.put(i, validators.getNode(i).getAddress());
|
||||
cheiPublice.put(i, pereche.publicRegistryForm());
|
||||
cheiPrivate.put(i, pereche.privateKey());
|
||||
}
|
||||
}
|
||||
|
||||
private CommitValidator armat(final long armedFrom) {
|
||||
return new CommitValidator(
|
||||
validators.getNodeAddresses(),
|
||||
round,
|
||||
expectedHash,
|
||||
expectedHash,
|
||||
new PqCommitEnforcement(armedFrom, registry));
|
||||
}
|
||||
|
||||
private Commit commitWithoutSeal(final int nod) {
|
||||
final SECPSignature ecdsa =
|
||||
validators.getNode(nod).getNodeKey().sign(Bytes32.wrap(expectedHash.getBytes()));
|
||||
return validators.getMessageFactory(nod).createCommit(round, expectedHash, ecdsa);
|
||||
}
|
||||
|
||||
private Commit commitWithSeal(final int nodEcdsa, final int indexFalcon) {
|
||||
final SECPSignature ecdsa =
|
||||
validators.getNode(nodEcdsa).getNodeKey().sign(Bytes32.wrap(expectedHash.getBytes()));
|
||||
final byte[] sig =
|
||||
SealSchemes.FALCON_512
|
||||
.sign(cheiPrivate.get(indexFalcon), expectedHash.getBytes().toArray())
|
||||
.orElseThrow();
|
||||
return validators
|
||||
.getMessageFactory(nodEcdsa)
|
||||
.createCommit(round, expectedHash, ecdsa, Optional.of(new FalconSeal(indexFalcon, Bytes.wrap(sig))));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void disarmedOldConstructorIsUpstreamBehaviour() {
|
||||
final CommitValidator old =
|
||||
new CommitValidator(validators.getNodeAddresses(), round, expectedHash, expectedHash);
|
||||
assertThat(old.validate(commitWithoutSeal(0))).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void armedRejectsCommitWithoutPqSeal() {
|
||||
assertThat(armat(HEIGHT).validate(commitWithoutSeal(0))).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void armedAcceptsCommitWithRealPqSeal() {
|
||||
for (int i = 0; i < VALIDATOR_COUNT; i++) {
|
||||
assertThat(armat(HEIGHT).validate(commitWithSeal(i, i))).isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void armedRejectsSealOfAnotherValidator() {
|
||||
// node 0's ECDSA message, index 1's Falcon seal: the author binding fails
|
||||
assertThat(armat(HEIGHT).validate(commitWithSeal(0, 1))).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void belowArmingHeightSealIsNotRequired() {
|
||||
assertThat(armat(HEIGHT + 1).validate(commitWithoutSeal(0))).isTrue();
|
||||
}
|
||||
}
|
||||
@ -1,159 +0,0 @@
|
||||
/* AERE full-PQ, step 1, the core's proofs. The key case is integration with REAL
|
||||
* cryptography: a true Falcon seal over the commit digest passes, one with a flipped bit
|
||||
* does not, and a seal bound to a DIFFERENT author cannot vouch for anyone else's vote.
|
||||
* The registry is a test double implementing the whole interface (the compiler is the
|
||||
* control: without a height no answer is possible -- the D2 inheritance). */
|
||||
package org.hyperledger.besu.consensus.qbft.core.validation;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.apache.tuweni.bytes.Bytes;
|
||||
import org.hyperledger.besu.consensus.common.bft.FalconSeal;
|
||||
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 org.junit.jupiter.api.Test;
|
||||
|
||||
class PqCommitEnforcementTest {
|
||||
|
||||
private static final long H_ARMARE = 1_000_000L;
|
||||
private static final Address VALIDATOR_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(7, 7, 7));
|
||||
|
||||
private final SecureRandom random = SecureRandomProvider.createSecureRandom();
|
||||
|
||||
/** Registru de test: legaturi index->adresa programate + verificare prin schema REALA. */
|
||||
private static final class RegistruDeTest implements PqSignerRegistry {
|
||||
final Map<Integer, Address> bindings;
|
||||
final Map<Integer, byte[]> keys;
|
||||
|
||||
RegistruDeTest(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(PqCommitEnforcement enforcement, FalconSeal sigiliuValid0) {}
|
||||
|
||||
/** O lume cu 2 validatori cu chei Falcon reale; sigiliul validatorului 0 peste DIGEST. */
|
||||
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(), DIGEST.getBytes().toArray()).orElseThrow();
|
||||
final RegistruDeTest reg =
|
||||
new RegistruDeTest(
|
||||
Map.of(0, VALIDATOR_0, 1, VALIDATOR_1),
|
||||
Map.of(0, k0.publicRegistryForm(), 1, k1.publicRegistryForm()));
|
||||
return new World(new PqCommitEnforcement(H_ARMARE, reg), new FalconSeal(0, Bytes.wrap(sig0)));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ sub si la granita
|
||||
|
||||
@Test
|
||||
void belowArmingHeightEverythingCountsEvenWithoutSeal() {
|
||||
final World l = world();
|
||||
assertThat(l.enforcement().refusal(H_ARMARE - 1, VALIDATOR_0, DIGEST, Optional.empty())).isEmpty();
|
||||
assertThat(l.enforcement().armedAt(H_ARMARE - 1)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void disarmedNeverEnforces() {
|
||||
final World l = world();
|
||||
final PqCommitEnforcement dezarmat =
|
||||
new PqCommitEnforcement(PqCommitEnforcement.DISARMED, new RegistruDeTest(Map.of(), Map.of()));
|
||||
assertThat(dezarmat.refusal(Long.MAX_VALUE - 1, VALIDATOR_0, DIGEST, Optional.empty())).isEmpty();
|
||||
assertThat(l).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void atTheExactArmingHeightEnforcementBites() {
|
||||
final World l = world();
|
||||
final Optional<String> refusal = l.enforcement().refusal(H_ARMARE, VALIDATOR_0, DIGEST, Optional.empty());
|
||||
assertThat(refusal).isPresent();
|
||||
assertThat(refusal.get()).contains("NO post-quantum seal").contains(String.valueOf(H_ARMARE));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ drumul fericit + negative
|
||||
|
||||
@Test
|
||||
void validRealSealCounts() {
|
||||
final World l = world();
|
||||
assertThat(l.enforcement().refusal(H_ARMARE, VALIDATOR_0, DIGEST, Optional.of(l.sigiliuValid0())))
|
||||
.isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void sealBoundToAnotherAuthorCannotVouch() {
|
||||
final World l = world();
|
||||
// sigiliul indexului 0 (legat de VALIDATOR_0) pe un mesaj SEMNAT de VALIDATOR_1
|
||||
final Optional<String> refusal =
|
||||
l.enforcement().refusal(H_ARMARE, VALIDATOR_1, DIGEST, Optional.of(l.sigiliuValid0()));
|
||||
assertThat(refusal).isPresent();
|
||||
assertThat(refusal.get()).contains("someone else");
|
||||
}
|
||||
|
||||
@Test
|
||||
void corruptedSignatureIsRefusedWithTheIndexNamed() {
|
||||
final World l = world();
|
||||
final byte[] stricat = l.sigiliuValid0().getSignature().toArray().clone();
|
||||
stricat[stricat.length / 2] ^= 0x01;
|
||||
final Optional<String> refusal =
|
||||
l.enforcement()
|
||||
.refusal(H_ARMARE, VALIDATOR_0, DIGEST, Optional.of(new FalconSeal(0, Bytes.wrap(stricat))));
|
||||
assertThat(refusal).isPresent();
|
||||
assertThat(refusal.get()).contains("does NOT verify").contains("index 0");
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownIndexIsRefusedNotTrusted() {
|
||||
final World l = world();
|
||||
final Optional<String> refusal =
|
||||
l.enforcement()
|
||||
.refusal(H_ARMARE, VALIDATOR_0, DIGEST, Optional.of(new FalconSeal(7, l.sigiliuValid0().getSignature())));
|
||||
assertThat(refusal).isPresent(); // a missing binding (null) is never "fine"
|
||||
}
|
||||
|
||||
@Test
|
||||
void sealOverADifferentDigestDoesNotCount() {
|
||||
final World l = world();
|
||||
final Hash altDigest = Hash.hash(Bytes.of(9, 9, 9));
|
||||
final Optional<String> refusal =
|
||||
l.enforcement().refusal(H_ARMARE, VALIDATOR_0, altDigest, Optional.of(l.sigiliuValid0()));
|
||||
assertThat(refusal).isPresent();
|
||||
assertThat(refusal.get()).contains("does NOT verify");
|
||||
}
|
||||
}
|
||||
@ -1,108 +0,0 @@
|
||||
/* AERE full-PQ, the PLUMBING: CommitValidator's production constructor (the 4-argument one,
|
||||
* the only one MessageValidator calls) self-installs from the system property. The
|
||||
* differential is the proof itself: same message, same constructor, the only difference is
|
||||
* the property -- disarmed passes, armed below the height passes, armed at the height
|
||||
* refuses. And the loud refusal: a broken value throws AERE-PQC-COMMIT-CONF-01 at
|
||||
* construction, because a mistyped comma must not silently boot the node disarmed. The
|
||||
* property is cleaned in finally so it cannot poison other classes in the same JVM (the
|
||||
* order-dependent-green lesson). */
|
||||
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.ConsensusRoundIdentifier;
|
||||
import org.hyperledger.besu.consensus.qbft.core.messagewrappers.Commit;
|
||||
import org.hyperledger.besu.consensus.qbft.core.types.QbftBlockCodec;
|
||||
import org.hyperledger.besu.crypto.SECPSignature;
|
||||
import org.hyperledger.besu.datatypes.Hash;
|
||||
|
||||
import org.apache.tuweni.bytes.Bytes32;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
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;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
public class PqCommitPlumbingTest {
|
||||
|
||||
private static final long HEIGHT = 7_777L;
|
||||
|
||||
private final ConsensusRoundIdentifier round = new ConsensusRoundIdentifier(HEIGHT, 0);
|
||||
private final Hash expectedHash = Hash.fromHexStringLenient("0x1");
|
||||
private QbftNodeList validators;
|
||||
private @Mock QbftBlockCodec qbftBlockCodec;
|
||||
|
||||
@BeforeEach
|
||||
public void setup() {
|
||||
validators = QbftNodeList.createNodes(3, qbftBlockCodec);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
public void curataProprietatea() {
|
||||
System.clearProperty(PqCommitEnforcement.PROPERTY_FORK_BLOCK);
|
||||
}
|
||||
|
||||
private CommitValidator validatorDeProductie() {
|
||||
// constructorul de 4 argumente: EXACT ce cheama MessageValidator.SubsequentMessageValidator
|
||||
return new CommitValidator(validators.getNodeAddresses(), round, expectedHash, expectedHash);
|
||||
}
|
||||
|
||||
private Commit commitWithoutSeal() {
|
||||
final SECPSignature ecdsa =
|
||||
validators.getNode(0).getNodeKey().sign(Bytes32.wrap(expectedHash.getBytes()));
|
||||
return validators.getMessageFactory(0).createCommit(round, expectedHash, ecdsa);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void withoutThePropertyProductionConstructorIsUpstream() {
|
||||
System.clearProperty(PqCommitEnforcement.PROPERTY_FORK_BLOCK);
|
||||
assertThat(validatorDeProductie().validate(commitWithoutSeal())).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void withThePropertyAtHeightUnsealedCommitStopsCounting() {
|
||||
try {
|
||||
System.setProperty(PqCommitEnforcement.PROPERTY_FORK_BLOCK, String.valueOf(HEIGHT));
|
||||
assertThat(validatorDeProductie().validate(commitWithoutSeal())).isFalse();
|
||||
} finally {
|
||||
System.clearProperty(PqCommitEnforcement.PROPERTY_FORK_BLOCK);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void withThePropertyAboveHeightNothingChangesYet() {
|
||||
try {
|
||||
System.setProperty(PqCommitEnforcement.PROPERTY_FORK_BLOCK, String.valueOf(HEIGHT + 1));
|
||||
assertThat(validatorDeProductie().validate(commitWithoutSeal())).isTrue();
|
||||
} finally {
|
||||
System.clearProperty(PqCommitEnforcement.PROPERTY_FORK_BLOCK);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void brokenValueRefusesLoudlyInsteadOfDisarming() {
|
||||
try {
|
||||
System.setProperty(PqCommitEnforcement.PROPERTY_FORK_BLOCK, "14,050,000");
|
||||
assertThatThrownBy(this::validatorDeProductie)
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("AERE-PQC-COMMIT-CONF-01");
|
||||
} finally {
|
||||
System.clearProperty(PqCommitEnforcement.PROPERTY_FORK_BLOCK);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void negativeValueRefusesLoudly() {
|
||||
try {
|
||||
System.setProperty(PqCommitEnforcement.PROPERTY_FORK_BLOCK, "-1");
|
||||
assertThatThrownBy(this::validatorDeProductie)
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("AERE-PQC-COMMIT-CONF-01");
|
||||
} finally {
|
||||
System.clearProperty(PqCommitEnforcement.PROPERTY_FORK_BLOCK);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,295 +0,0 @@
|
||||
/* AERE HYBRID, step 2: HYBRID CERTIFICATE enforcement at the commit quorum.
|
||||
*
|
||||
* Everything measured here uses REAL cryptography (Falcon-512 + SLH-DSA-128s generated on
|
||||
* every run, TEST keys) and a REAL hybrid registry built from properties, i.e. exactly the
|
||||
* production loading path. The validators' REAL keys are not generated here and are not
|
||||
* generated at all without the founder's ceremony and signature.
|
||||
*
|
||||
* The thesis it proves: above the height where the schedule requires two families, a vote
|
||||
* carrying only one does NOT count. A half hybrid is worth the weakest family present,
|
||||
* so a missing scheme refuses, it does not degrade. */
|
||||
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.HybridSignerRegistry;
|
||||
import org.hyperledger.besu.consensus.common.bft.PqSchemeSchedule;
|
||||
import org.hyperledger.besu.consensus.common.bft.PqSignerRegistry;
|
||||
import org.hyperledger.besu.consensus.common.bft.SchemeSeal;
|
||||
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.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Properties;
|
||||
|
||||
import org.apache.tuweni.bytes.Bytes;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
public class PqHybridEnforcementTest {
|
||||
|
||||
private static final long H_ARMARE = 1_000L;
|
||||
private static final long H_HIBRID = 2_000L;
|
||||
private static final Address VALIDATOR_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(4, 2));
|
||||
|
||||
private final SecureRandom random = SecureRandomProvider.createSecureRandom();
|
||||
|
||||
private SealScheme.GeneratedPair falcon0;
|
||||
private SealScheme.GeneratedPair slh0;
|
||||
private HybridSignerRegistry registry;
|
||||
private PqSchemeSchedule orar;
|
||||
|
||||
/** Registrul Falcon vechi: leaga indexul 0 de VALIDATOR_0 si verifica cu schema reala. */
|
||||
private PqSignerRegistry registruFalcon() {
|
||||
return new PqSignerRegistry() {
|
||||
@Override
|
||||
public Address addressForIndexAtHistoric(final long blockNumber, final int validatorIndex) {
|
||||
return addressForIndexAtOwnHead(blockNumber, validatorIndex);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Address addressForIndexAtOwnHead(final long blockNumber, final int validatorIndex) {
|
||||
return validatorIndex == 0 ? VALIDATOR_0 : null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean verifyAtHistoric(
|
||||
final long b, final int i, final Bytes message, final Bytes signature) {
|
||||
return verifyAtOwnHead(b, i, message, signature);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean verifyAtOwnHead(
|
||||
final long b, final int i, final Bytes message, final Bytes signature) {
|
||||
return i == 0
|
||||
&& SealSchemes.FALCON_512.verifyRaw(
|
||||
falcon0.publicRegistryForm(), message.toArray(), signature.toArray());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
public void setup() {
|
||||
falcon0 = SealSchemes.FALCON_512.generate(random);
|
||||
slh0 = SealSchemes.SLH_DSA_128S.generate(random);
|
||||
|
||||
final Properties p = new Properties();
|
||||
p.setProperty("formatVersion", HybridSignerRegistry.FORMAT_VERSION);
|
||||
p.setProperty("chainId", "2800");
|
||||
p.setProperty("count", "1");
|
||||
p.setProperty("0.addr", VALIDATOR_0.toHexString());
|
||||
p.setProperty(
|
||||
"0.key." + SealSchemes.FALCON_512.id(),
|
||||
Bytes.wrap(falcon0.publicRegistryForm()).toHexString());
|
||||
p.setProperty(
|
||||
"0.key." + SealSchemes.SLH_DSA_128S.id(),
|
||||
Bytes.wrap(slh0.publicRegistryForm()).toHexString());
|
||||
registry = HybridSignerRegistry.fromProperties(p, "proba");
|
||||
|
||||
// pana la H_HIBRID doar Falcon; de acolo AMANDOUA familiile
|
||||
orar =
|
||||
PqSchemeSchedule.parse(
|
||||
H_ARMARE
|
||||
+ ":"
|
||||
+ SealSchemes.FALCON_512.id()
|
||||
+ ","
|
||||
+ H_HIBRID
|
||||
+ ":"
|
||||
+ SealSchemes.FALCON_512.id()
|
||||
+ "+"
|
||||
+ SealSchemes.SLH_DSA_128S.id());
|
||||
}
|
||||
|
||||
private PqCommitEnforcement hibrid() {
|
||||
return new PqCommitEnforcement(H_ARMARE, registruFalcon(), orar, registry);
|
||||
}
|
||||
|
||||
private FalconSeal sigiliuFalcon(final int index) {
|
||||
return new FalconSeal(
|
||||
index,
|
||||
Bytes.wrap(
|
||||
SealSchemes.FALCON_512
|
||||
.sign(falcon0.privateKey(), DIGEST.getBytes().toArray())
|
||||
.orElseThrow()));
|
||||
}
|
||||
|
||||
private SchemeSeal sigiliuSlh(final int index, final Hash peste) {
|
||||
return new SchemeSeal(
|
||||
SealSchemes.SLH_DSA_128S.wireId(),
|
||||
index,
|
||||
Bytes.wrap(
|
||||
SealSchemes.SLH_DSA_128S
|
||||
.sign(slh0.privateKey(), peste.getBytes().toArray())
|
||||
.orElseThrow()));
|
||||
}
|
||||
|
||||
// ============================================================ configuratia
|
||||
|
||||
@Test
|
||||
public void halfAHybridConfigurationRefusesAtConstruction() {
|
||||
assertThatThrownBy(
|
||||
() -> new PqCommitEnforcement(H_ARMARE, registruFalcon(), orar, null))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("AERE-PQC-COMMIT-CONF-03");
|
||||
assertThatThrownBy(
|
||||
() -> new PqCommitEnforcement(H_ARMARE, registruFalcon(), null, registry))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("AERE-PQC-COMMIT-CONF-03");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void falconOnlyModeIgnoresExtrasEntirely() {
|
||||
final PqCommitEnforcement doarFalcon = new PqCommitEnforcement(H_ARMARE, registruFalcon());
|
||||
assertThat(
|
||||
doarFalcon.refusal(
|
||||
H_HIBRID, VALIDATOR_0, DIGEST, Optional.of(sigiliuFalcon(0)), List.of()))
|
||||
.isEmpty();
|
||||
}
|
||||
|
||||
// ============================================================ sub si peste treapta hibrida
|
||||
|
||||
@Test
|
||||
public void belowTheHybridStepFalconAloneIsEnough() {
|
||||
assertThat(
|
||||
hibrid()
|
||||
.refusal(
|
||||
H_HIBRID - 1, VALIDATOR_0, DIGEST, Optional.of(sigiliuFalcon(0)), List.of()))
|
||||
.isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void atTheHybridStepFalconAloneNoLongerCounts() {
|
||||
final Optional<String> refusal =
|
||||
hibrid()
|
||||
.refusal(H_HIBRID, VALIDATOR_0, DIGEST, Optional.of(sigiliuFalcon(0)), List.of());
|
||||
assertThat(refusal).isPresent();
|
||||
assertThat(refusal.get()).contains("carries no").contains(SealSchemes.SLH_DSA_128S.id());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aFullHybridCertificateCounts() {
|
||||
assertThat(
|
||||
hibrid()
|
||||
.refusal(
|
||||
H_HIBRID,
|
||||
VALIDATOR_0,
|
||||
DIGEST,
|
||||
Optional.of(sigiliuFalcon(0)),
|
||||
List.of(sigiliuSlh(0, DIGEST))))
|
||||
.isEmpty();
|
||||
}
|
||||
|
||||
// ============================================================ controalele negative
|
||||
|
||||
@Test
|
||||
public void aHybridSealOverAnotherDigestDoesNotCount() {
|
||||
final Hash altul = Hash.hash(Bytes.of(9, 9));
|
||||
final Optional<String> refusal =
|
||||
hibrid()
|
||||
.refusal(
|
||||
H_HIBRID,
|
||||
VALIDATOR_0,
|
||||
DIGEST,
|
||||
Optional.of(sigiliuFalcon(0)),
|
||||
List.of(sigiliuSlh(0, altul)));
|
||||
assertThat(refusal).isPresent();
|
||||
assertThat(refusal.get()).contains("does NOT verify");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aHybridSealForAnotherIndexIsRefused() {
|
||||
final Optional<String> refusal =
|
||||
hibrid()
|
||||
.refusal(
|
||||
H_HIBRID,
|
||||
VALIDATOR_0,
|
||||
DIGEST,
|
||||
Optional.of(sigiliuFalcon(0)),
|
||||
List.of(sigiliuSlh(1, DIGEST)));
|
||||
assertThat(refusal).isPresent();
|
||||
assertThat(refusal.get()).contains("one commit, one signer");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aCorruptedHybridSealIsRefusedWithTheSchemeNamed() {
|
||||
final SchemeSeal bun = sigiliuSlh(0, DIGEST);
|
||||
final byte[] stricat = bun.getSignature().toArray().clone();
|
||||
stricat[stricat.length / 3] ^= 0x01;
|
||||
final Optional<String> refusal =
|
||||
hibrid()
|
||||
.refusal(
|
||||
H_HIBRID,
|
||||
VALIDATOR_0,
|
||||
DIGEST,
|
||||
Optional.of(sigiliuFalcon(0)),
|
||||
List.of(
|
||||
new SchemeSeal(
|
||||
SealSchemes.SLH_DSA_128S.wireId(), 0, Bytes.wrap(stricat))));
|
||||
assertThat(refusal).isPresent();
|
||||
assertThat(refusal.get())
|
||||
.contains("does NOT verify")
|
||||
.contains(SealSchemes.SLH_DSA_128S.id());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void anIndexNotBoundToTheAuthorInTheHybridRegistryIsRefused() {
|
||||
// the Falcon registry binds index 0 to VALIDATOR_0; we ask for the verdict as if the
|
||||
// author were VALIDATOR_1: the Falcon path refuses first, so the hybrid is never touched
|
||||
final Optional<String> refusal =
|
||||
hibrid()
|
||||
.refusal(
|
||||
H_HIBRID,
|
||||
VALIDATOR_1,
|
||||
DIGEST,
|
||||
Optional.of(sigiliuFalcon(0)),
|
||||
List.of(sigiliuSlh(0, DIGEST)));
|
||||
assertThat(refusal).isPresent();
|
||||
assertThat(refusal.get()).contains("someone else");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void theTwoFamiliesAreIndependentAndTheProofSaysSo() {
|
||||
// the control that gives the hybrid its meaning: a Falcon signature does not pass as
|
||||
// SLH-DSA and vice versa. If it did, "hybrid" would be the same leg twice.
|
||||
final byte[] message = DIGEST.getBytes().toArray();
|
||||
final byte[] sigF =
|
||||
SealSchemes.FALCON_512.sign(falcon0.privateKey(), message).orElseThrow();
|
||||
final byte[] sigS =
|
||||
SealSchemes.SLH_DSA_128S.sign(slh0.privateKey(), message).orElseThrow();
|
||||
assertThat(SealSchemes.SLH_DSA_128S.verifyRaw(slh0.publicRegistryForm(), message, sigF))
|
||||
.isFalse();
|
||||
assertThat(SealSchemes.FALCON_512.verifyRaw(falcon0.publicRegistryForm(), message, sigS))
|
||||
.isFalse();
|
||||
// and an SLH-DSA seal presented under the Falcon label cannot enter the hybrid slot,
|
||||
// because lookup there goes by scheme label
|
||||
final Optional<String> refusal =
|
||||
hibrid()
|
||||
.refusal(
|
||||
H_HIBRID,
|
||||
VALIDATOR_0,
|
||||
DIGEST,
|
||||
Optional.of(sigiliuFalcon(0)),
|
||||
List.of(new SchemeSeal(SealSchemes.FALCON_512.wireId(), 0, Bytes.wrap(sigS))));
|
||||
assertThat(refusal).isPresent();
|
||||
assertThat(refusal.get()).contains("carries no");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void theRegistryItselfHoldsBothFamiliesForTheSameValidator() {
|
||||
assertThat(registry.publicKey(0, SealSchemes.FALCON_512.id())).isPresent();
|
||||
assertThat(registry.publicKey(0, SealSchemes.SLH_DSA_128S.id())).isPresent();
|
||||
assertThat(registry.coverage(SealSchemes.SLH_DSA_128S.id())).isEqualTo(1);
|
||||
assertThat(registry.address(0)).isPresent();
|
||||
assertThat(Map.of()).isEmpty();
|
||||
}
|
||||
}
|
||||
@ -1,180 +0,0 @@
|
||||
/*
|
||||
* Copyright contributors to Besu.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
|
||||
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations under the License.
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
package org.hyperledger.besu.consensus.qbft.core.validation;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import org.hyperledger.besu.consensus.common.bft.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.Test;
|
||||
|
||||
/**
|
||||
* CRYPTOGRAPHIC AGILITY of the PREPARE enforcement: the same consensus code, UNTOUCHED, accepts a
|
||||
* seal made with a DIFFERENT post-quantum scheme.
|
||||
*
|
||||
* <p>WHY THIS EXISTS - it is a question asked of my own work from the night of the 28th to the
|
||||
* 29th. The anchor has been scheme-agile since 2026-08-28 (finding D-266): the hybrid registry
|
||||
* holds keys PER SCHEME and dispatches through {@code SealSchemes.byId}. The new surface, PREPARE,
|
||||
* reads at first sight as nailed to Falcon: the production wiring goes through
|
||||
* {@code PqSignerRegistry.falconSealSupport()}, and that lands in {@code verifyWithKey}, which
|
||||
* names {@code SealSchemes.FALCON_512} literally. The question that matters is not "is the wiring
|
||||
* agile?" - plainly it is not - but <b>where exactly the nail is: in the enforcement class, or only
|
||||
* in the wiring?</b>
|
||||
*
|
||||
* <p>This test answers by measurement. {@link PqPrepareEnforcement} receives the registry through
|
||||
* its constructor and names no scheme at all; so if it is given a registry that verifies under
|
||||
* SLH-DSA, a PREPARE signed with SLH-DSA must pass <b>without touching one line of consensus
|
||||
* code</b>. If it passes, the nail is only in the wiring and comes out with a new binding rather
|
||||
* than a rewrite. If it does not pass, the enforcement itself has to be opened up - and that would
|
||||
* be a far more expensive finding.
|
||||
*
|
||||
* <p>SLH-DSA is the very second scheme the founder chose on 7 August for the hybrid certificate,
|
||||
* and it is already live as a precompile on the chain from block 9,189,161. It is not a scheme
|
||||
* invented for this test.
|
||||
*
|
||||
* <p>WHAT THIS DOES NOT PROVE: it does not say the fleet can run this way today. The production
|
||||
* wiring stays Falcon-only, and that is written as such in the findings register. What is measured
|
||||
* here is only where the limit sits.
|
||||
*/
|
||||
class PqPrepareAgilityTest {
|
||||
|
||||
private static final long H_ARMARE = 1_000L;
|
||||
private static final int ROUND = 2;
|
||||
private static final long CHAIN_ID = 2800L;
|
||||
private static final Address VALIDATOR_0 = Address.fromHexString("0x" + "cc".repeat(20));
|
||||
private static final Hash DIGEST = Hash.hash(Bytes.of(9, 9, 9));
|
||||
|
||||
private final SecureRandom random = SecureRandomProvider.createSecureRandom();
|
||||
|
||||
/** A registry that verifies under A GIVEN SCHEME, whichever it is. Nothing Falcon inside. */
|
||||
private static final class RegistryPerScheme implements PqSignerRegistry {
|
||||
private final SealScheme scheme;
|
||||
private final Map<Integer, Address> bindings;
|
||||
private final Map<Integer, byte[]> keys;
|
||||
|
||||
RegistryPerScheme(
|
||||
final SealScheme scheme, final Map<Integer, Address> bindings, final Map<Integer, byte[]> keys) {
|
||||
this.scheme = scheme;
|
||||
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);
|
||||
return pk != null && scheme.verifyRaw(pk, message.toArray(), signature.toArray());
|
||||
}
|
||||
}
|
||||
|
||||
private Bytes32 message() {
|
||||
return PqAnchor.prepareMessage(CHAIN_ID, H_ARMARE, ROUND, DIGEST.getBytes());
|
||||
}
|
||||
|
||||
/** A PREPARE signed with the given scheme, enforced over a registry on that same scheme. */
|
||||
private boolean passesUnder(final SealScheme scheme) {
|
||||
final SealScheme.GeneratedPair k = scheme.generate(random);
|
||||
final byte[] sig = scheme.sign(k.privateKey(), message().toArray()).orElseThrow();
|
||||
final PqPrepareEnforcement enforcement =
|
||||
new PqPrepareEnforcement(
|
||||
H_ARMARE,
|
||||
new RegistryPerScheme(
|
||||
scheme, Map.of(0, VALIDATOR_0), Map.of(0, k.publicRegistryForm())),
|
||||
CHAIN_ID);
|
||||
final Optional<String> refusal =
|
||||
enforcement.refusal(
|
||||
H_ARMARE, ROUND, VALIDATOR_0, DIGEST, Optional.of(new FalconSeal(0, Bytes.wrap(sig))));
|
||||
return refusal.isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void aPrepareSignedWithFALCONPasses() {
|
||||
// THE WITNESS. Without it, a "passes" for SLH-DSA would not say whether the enforcement
|
||||
// verifies anything at all.
|
||||
assertThat(passesUnder(SealSchemes.FALCON_512)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void aPrepareSignedWithSLHDSAPassesTHESAMEWay() {
|
||||
// The same enforcement class, the same message, THE SAME consensus code - different maths.
|
||||
assertThat(passesUnder(SealSchemes.SLH_DSA_128S)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void theEnforcementNAMESNoSchemeAtAll() {
|
||||
// The control that makes the test above mean something: if the registry verifies under SLH-DSA
|
||||
// but the seal was made with Falcon, it must be REFUSED. Otherwise "passes" could just mean
|
||||
// "does not verify".
|
||||
final SealScheme.GeneratedPair falcon = SealSchemes.FALCON_512.generate(random);
|
||||
final SealScheme.GeneratedPair slh = SealSchemes.SLH_DSA_128S.generate(random);
|
||||
final byte[] sigFalcon =
|
||||
SealSchemes.FALCON_512.sign(falcon.privateKey(), message().toArray()).orElseThrow();
|
||||
|
||||
final PqPrepareEnforcement enforcement =
|
||||
new PqPrepareEnforcement(
|
||||
H_ARMARE,
|
||||
new RegistryPerScheme(
|
||||
SealSchemes.SLH_DSA_128S,
|
||||
Map.of(0, VALIDATOR_0),
|
||||
Map.of(0, slh.publicRegistryForm())),
|
||||
CHAIN_ID);
|
||||
final Optional<String> refusal =
|
||||
enforcement.refusal(
|
||||
H_ARMARE,
|
||||
ROUND,
|
||||
VALIDATOR_0,
|
||||
DIGEST,
|
||||
Optional.of(new FalconSeal(0, Bytes.wrap(sigFalcon))));
|
||||
assertThat(refusal).isPresent();
|
||||
}
|
||||
|
||||
@Test
|
||||
void theTwoSchemesReallyAreDIFFERENT() {
|
||||
// The second control of the method: if the two schemes happened to be the same implementation,
|
||||
// the agility test would be a tautology. Their identities and key lengths must differ.
|
||||
assertThat(SealSchemes.FALCON_512.id()).isNotEqualTo(SealSchemes.SLH_DSA_128S.id());
|
||||
assertThat(SealSchemes.FALCON_512.publicKeyLength())
|
||||
.isNotEqualTo(SealSchemes.SLH_DSA_128S.publicKeyLength());
|
||||
}
|
||||
}
|
||||
@ -1,266 +0,0 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* PREPARE ENFORCEMENT, step 4. The structure follows PqCommitEnforcementTest deliberately.
|
||||
*
|
||||
* <p>What is proven here, each item closing one way of being wrong:
|
||||
*
|
||||
* <ol>
|
||||
* <li>below the arming height NOTHING changes - the condition for the binary to sit on the fleet;
|
||||
* <li>above it, a PREPARE without a seal does NOT count;
|
||||
* <li>a seal from ANOTHER validator does not vouch for this author;
|
||||
* <li>a seal over a DIFFERENT MESSAGE does not pass - in particular one over the COMMIT message,
|
||||
* which is exactly the attack that domain separation closes;
|
||||
* <li>a seal from a DIFFERENT ROUND does not pass;
|
||||
* <li>a mistyped configuration REFUSES, it does not disarm.
|
||||
* </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 PqPrepareEnforcementTest {
|
||||
|
||||
private static final long H_ARMARE = 1_000_000L;
|
||||
private static final int ROUND = 3;
|
||||
private static final Address VALIDATOR_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(7, 7, 7));
|
||||
private static final long CHAIN_ID = 2800L;
|
||||
|
||||
private final SecureRandom random = SecureRandomProvider.createSecureRandom();
|
||||
|
||||
@AfterEach
|
||||
void curata() {
|
||||
System.clearProperty(PqPrepareEnforcement.PROPERTY_FORK_BLOCK);
|
||||
}
|
||||
|
||||
/** Registru de test: legaturi index->adresa programate + verificare prin schema REALA. */
|
||||
private static final class RegistruDeTest implements PqSignerRegistry {
|
||||
final Map<Integer, Address> bindings;
|
||||
final Map<Integer, byte[]> keys;
|
||||
|
||||
RegistruDeTest(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(
|
||||
PqPrepareEnforcement enforcement,
|
||||
FalconSeal valid0,
|
||||
SealScheme.GeneratedPair k0,
|
||||
SealScheme.GeneratedPair k1) {}
|
||||
|
||||
private Bytes32 mesajPrepare(final long h, final int round) {
|
||||
return PqAnchor.prepareMessage(CHAIN_ID, h, round, DIGEST.getBytes());
|
||||
}
|
||||
|
||||
/** Two validators with real Falcon keys; validator 0's seal over the PREPARE message at H_ARMARE. */
|
||||
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(), mesajPrepare(H_ARMARE, ROUND).toArray())
|
||||
.orElseThrow();
|
||||
final RegistruDeTest reg =
|
||||
new RegistruDeTest(
|
||||
Map.of(0, VALIDATOR_0, 1, VALIDATOR_1),
|
||||
Map.of(0, k0.publicRegistryForm(), 1, k1.publicRegistryForm()));
|
||||
return new World(
|
||||
new PqPrepareEnforcement(H_ARMARE, 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 belowTheArmingHeightAnUnsealedPrepareDOESCount() {
|
||||
final World l = world();
|
||||
assertThat(l.enforcement().armedAt(H_ARMARE - 1)).isFalse();
|
||||
assertThat(l.enforcement().refusal(H_ARMARE - 1, ROUND, VALIDATOR_0, DIGEST, Optional.empty()))
|
||||
.isEmpty();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// 2. ABOVE it, a PREPARE without a seal does not count.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
@Test
|
||||
void aboveTheHeightAnUnsealedPrepareDoesNOTCount() {
|
||||
final World l = world();
|
||||
final Optional<String> refusal =
|
||||
l.enforcement().refusal(H_ARMARE, ROUND, VALIDATOR_0, DIGEST, Optional.empty());
|
||||
assertThat(refusal).isPresent();
|
||||
assertThat(refusal.get()).contains("carries NO post-quantum seal");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// 3. A GOOD seal from the author passes.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
@Test
|
||||
void aGoodSealFromTheAuthorPasses() {
|
||||
final World l = world();
|
||||
assertThat(l.enforcement().refusal(H_ARMARE, ROUND, VALIDATOR_0, DIGEST, Optional.of(l.valid0())))
|
||||
.isEmpty();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// 4. The same seal, a different author: it does not vouch for somebody else.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
@Test
|
||||
void aSealDoesNotVouchForAnotherAuthor() {
|
||||
final World l = world();
|
||||
final Optional<String> refusal =
|
||||
l.enforcement().refusal(H_ARMARE, ROUND, VALIDATOR_1, DIGEST, Optional.of(l.valid0()));
|
||||
assertThat(refusal).isPresent();
|
||||
assertThat(refusal.get()).contains("cannot vouch for someone else's vote");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// 5. DOMAIN SEPARATION, and this is the security test of the whole step: a seal given HONESTLY
|
||||
// over the COMMIT message must not pass as a PREPARE seal.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
@Test
|
||||
void aSealOverTheCOMMITMessageDoesNotPassAsPREPARE() {
|
||||
final World l = world();
|
||||
final Bytes32 mesajCommit = PqAnchor.commitMessage(CHAIN_ID, H_ARMARE, DIGEST.getBytes());
|
||||
final byte[] sigCommit =
|
||||
SealSchemes.FALCON_512.sign(l.k0().privateKey(), mesajCommit.toArray()).orElseThrow();
|
||||
|
||||
final Optional<String> refusal =
|
||||
l.enforcement()
|
||||
.refusal(
|
||||
H_ARMARE, ROUND, VALIDATOR_0, DIGEST, Optional.of(new FalconSeal(0, Bytes.wrap(sigCommit))));
|
||||
assertThat(refusal).isPresent();
|
||||
assertThat(refusal.get()).contains("does NOT verify over the prepare message");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// 6. The ROUND is in the preimage: a seal from a failed round does not justify another one.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
@Test
|
||||
void aSealFromAnotherROUNDDoesNotPass() {
|
||||
final World l = world();
|
||||
final byte[] sigAltaRunda =
|
||||
SealSchemes.FALCON_512
|
||||
.sign(l.k0().privateKey(), mesajPrepare(H_ARMARE, ROUND + 1).toArray())
|
||||
.orElseThrow();
|
||||
final Optional<String> refusal =
|
||||
l.enforcement()
|
||||
.refusal(
|
||||
H_ARMARE, ROUND, VALIDATOR_0, DIGEST, Optional.of(new FalconSeal(0, Bytes.wrap(sigAltaRunda))));
|
||||
assertThat(refusal).isPresent();
|
||||
assertThat(refusal.get()).contains("does NOT verify over the prepare message");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// 7. An index the registry binds to nobody.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
@Test
|
||||
void anUNBOUNDIndexDoesNotPass() {
|
||||
final World l = world();
|
||||
final Optional<String> refusal =
|
||||
l.enforcement()
|
||||
.refusal(H_ARMARE, ROUND, VALIDATOR_0, DIGEST, Optional.of(new FalconSeal(99, l.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(PqPrepareEnforcement.fromSystemConfig()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void oValoareBunaArmeaza() {
|
||||
System.setProperty(PqPrepareEnforcement.PROPERTY_FORK_BLOCK, "1234567");
|
||||
final PqPrepareEnforcement e = PqPrepareEnforcement.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[] {"nu-e-numar", "1_234_567", "-1", "1e6"}) {
|
||||
System.setProperty(PqPrepareEnforcement.PROPERTY_FORK_BLOCK, bad);
|
||||
assertThatThrownBy(PqPrepareEnforcement::fromSystemConfig)
|
||||
.as("valoarea '%s'", bad)
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("AERE-PQC-PREPARE-ENF-01");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void theENFORCEMENTGateIsNotTheEMISSIONGate() {
|
||||
// Doua proprietati distincte: se poate EMITE luni de zile fara sa se IMPUNA nimic. Daca ar fi
|
||||
// una singura, primul nod care incepe sa emita ar incepe si sa refuze, si aia e o zi de flag.
|
||||
assertThat(PqPrepareEnforcement.PROPERTY_FORK_BLOCK)
|
||||
.isNotEqualTo("aere.pq.preparePq.attachBlock");
|
||||
}
|
||||
}
|
||||
@ -1,118 +0,0 @@
|
||||
/*
|
||||
* Copyright contributors to Besu.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
|
||||
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations under the License.
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
package org.hyperledger.besu.consensus.qbft.core.validation;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import org.hyperledger.besu.consensus.common.bft.ConsensusRoundIdentifier;
|
||||
import org.hyperledger.besu.consensus.common.bft.PqSignerRegistry;
|
||||
import org.hyperledger.besu.consensus.qbft.core.messagewrappers.Prepare;
|
||||
import org.hyperledger.besu.consensus.qbft.core.types.QbftBlockCodec;
|
||||
import org.hyperledger.besu.datatypes.Address;
|
||||
import org.hyperledger.besu.datatypes.Hash;
|
||||
|
||||
import org.apache.tuweni.bytes.Bytes;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mock;
|
||||
|
||||
/**
|
||||
* THE WIRING, not the class: does {@link PrepareValidator} actually CALL the enforcement?
|
||||
*
|
||||
* <p>Without this test we would have exactly the situation paid for on 2026-08-28 at the restore
|
||||
* step - a class present in the binary, environment variables visible to the process, and code that
|
||||
* never runs. "I checked what I added" does not mean "I checked that it is wired".
|
||||
*
|
||||
* <p>The registry here REFUSES everything, so this does not measure cryptography (that has its own
|
||||
* test), only whether the decision passes through the hook at all. The pair below is all it takes:
|
||||
* the same message, once with enforcement and once without.
|
||||
*/
|
||||
public class PrepareValidatorPqWiringTest {
|
||||
|
||||
private static final int VALIDATOR_COUNT = 4;
|
||||
private static final long HEIGHT = 1L;
|
||||
|
||||
private final ConsensusRoundIdentifier round = new ConsensusRoundIdentifier((int) HEIGHT, 0);
|
||||
private final Hash expectedHash = Hash.fromHexStringLenient("0x1");
|
||||
@Mock private QbftBlockCodec blockEncoder;
|
||||
private QbftNodeList validators;
|
||||
|
||||
@BeforeEach
|
||||
public void setup() {
|
||||
validators = QbftNodeList.createNodes(VALIDATOR_COUNT, blockEncoder);
|
||||
}
|
||||
|
||||
/** A registry that binds no index and verifies nothing. */
|
||||
private static final class RegistruGol 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;
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void withoutEnforcementAnUnsealedPrepareISValid() {
|
||||
final PrepareValidator validator =
|
||||
new PrepareValidator(validators.getNodeAddresses(), round, expectedHash, null);
|
||||
final Prepare msg = validators.getMessageFactory(0).createPrepare(round, expectedHash);
|
||||
assertThat(validator.validate(msg)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void withTheEnforcementARMEDTheSamePrepareISRefused() {
|
||||
// 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 PrepareValidator validator =
|
||||
new PrepareValidator(
|
||||
validators.getNodeAddresses(),
|
||||
round,
|
||||
expectedHash,
|
||||
new PqPrepareEnforcement(HEIGHT, new RegistruGol(), 2800L));
|
||||
final Prepare msg = validators.getMessageFactory(0).createPrepare(round, expectedHash);
|
||||
assertThat(validator.validate(msg)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void withTheEnforcementBELOWItsHeightTheSamePrepareISValid() {
|
||||
// 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 PrepareValidator validator =
|
||||
new PrepareValidator(
|
||||
validators.getNodeAddresses(),
|
||||
round,
|
||||
expectedHash,
|
||||
new PqPrepareEnforcement(HEIGHT + 1, new RegistruGol(), 2800L));
|
||||
final Prepare msg = validators.getMessageFactory(0).createPrepare(round, expectedHash);
|
||||
assertThat(validator.validate(msg)).isTrue();
|
||||
}
|
||||
}
|
||||
@ -1,298 +0,0 @@
|
||||
/*
|
||||
* 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 com.google.common.collect.Iterables.toArray;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.hyperledger.besu.consensus.qbft.core.validation.ValidationTestHelpers.createEmptyRoundChangePayloads;
|
||||
import static org.hyperledger.besu.consensus.qbft.core.validation.ValidationTestHelpers.createPreparePayloads;
|
||||
import static org.hyperledger.besu.consensus.qbft.core.validation.ValidationTestHelpers.createPreparedCertificate;
|
||||
import static org.mockito.Mockito.any;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import org.hyperledger.besu.consensus.common.bft.BftHelpers;
|
||||
import org.hyperledger.besu.consensus.common.bft.ConsensusRoundHelpers;
|
||||
import org.hyperledger.besu.consensus.common.bft.ConsensusRoundIdentifier;
|
||||
import org.hyperledger.besu.consensus.common.bft.blockcreation.ProposerSelector;
|
||||
import org.hyperledger.besu.consensus.common.bft.payload.SignedData;
|
||||
import org.hyperledger.besu.consensus.qbft.core.QbftBlockTestFixture;
|
||||
import org.hyperledger.besu.consensus.qbft.core.messagewrappers.Proposal;
|
||||
import org.hyperledger.besu.consensus.qbft.core.messagewrappers.RoundChange;
|
||||
import org.hyperledger.besu.consensus.qbft.core.payload.PreparedRoundMetadata;
|
||||
import org.hyperledger.besu.consensus.qbft.core.payload.RoundChangePayload;
|
||||
import org.hyperledger.besu.consensus.qbft.core.statemachine.PreparedCertificate;
|
||||
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.consensus.qbft.core.types.QbftBlockInterface;
|
||||
import org.hyperledger.besu.consensus.qbft.core.types.QbftBlockValidator;
|
||||
import org.hyperledger.besu.consensus.qbft.core.types.QbftBlockValidator.ValidationResult;
|
||||
import org.hyperledger.besu.consensus.qbft.core.types.QbftProtocolSchedule;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.apache.tuweni.bytes.Bytes32;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
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 COUPLING of round-change justifications to the PREPARE enforcement.
|
||||
*
|
||||
* <p>WHY THIS FILE EXISTS - it is an hour-long scar, from 2026-08-29. The step-6 design asked for a
|
||||
* SEPARATE gate for justifications, armed later than the PREPARE one, so as not to invalidate "old
|
||||
* justifications". Two things read in the code overturned that request:
|
||||
*
|
||||
* <ol>
|
||||
* <li>a justification cannot be old: {@code validatePrepares} uses
|
||||
* {@code new ConsensusRoundIdentifier(chainHeight, metadata.getPreparedRound())}, so every
|
||||
* attached PREPARE is from the height being decided NOW, only from an earlier round;
|
||||
* <li>the coupling already exists: {@code RoundChangeMessageValidator} builds a
|
||||
* {@link PrepareValidator} with the three-argument constructor, and that one <b>wires its own
|
||||
* enforcement</b> from the system configuration.
|
||||
* </ol>
|
||||
*
|
||||
* <p>So a gate armed later would not be a precaution, it would be a BACK DOOR: the same unsealed
|
||||
* PREPARE, refused when it arrives on its own, would be accepted when it arrives wrapped in a round
|
||||
* change. The coupling is the security property itself - but until today it followed from an
|
||||
* implicit constructor and NOTHING guarded it. Anyone "tidying up" that constructor six months from
|
||||
* now would open the back door without a single test failing. From here on, this one fails.
|
||||
*
|
||||
* <p>WHAT IT DOES NOT PROVE, written down because the gap is visible: it does not prove that a
|
||||
* justification with VALID seals passes, because the self-wired enforcement uses the live registry
|
||||
* of the process and a test one cannot be injected along that path. That case is covered by
|
||||
* {@link PqPrepareEnforcementTest} at the message level and by the network run (F81, scenario A) at
|
||||
* the chain level. What is proven here is the coupling, in both directions, and that the gate is
|
||||
* bound to HEIGHT inside the justifications too.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
public class RoundChangeJustificationPqTest {
|
||||
|
||||
@Mock private RoundChangePayloadValidator payloadValidator;
|
||||
@Mock private QbftProtocolSchedule protocolSchedule;
|
||||
@Mock private QbftBlockValidator blockValidator;
|
||||
@Mock private QbftBlockCodec blockEncoder;
|
||||
@Mock private QbftBlockInterface blockInterface;
|
||||
@Mock private ProposerSelector proposerSelector;
|
||||
|
||||
private static final int VALIDATOR_COUNT = 4;
|
||||
private static final int CHAIN_HEIGHT = 3;
|
||||
|
||||
private final ConsensusRoundIdentifier targetRound =
|
||||
new ConsensusRoundIdentifier(CHAIN_HEIGHT, 3);
|
||||
private final ConsensusRoundIdentifier roundIdentifier =
|
||||
ConsensusRoundHelpers.createFrom(targetRound, 0, -1);
|
||||
|
||||
private QbftNodeList validators;
|
||||
|
||||
@BeforeEach
|
||||
public void setup() {
|
||||
validators = QbftNodeList.createNodes(VALIDATOR_COUNT, blockEncoder);
|
||||
lenient().when(protocolSchedule.getBlockValidator(any())).thenReturn(blockValidator);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
public void curata() {
|
||||
System.clearProperty(PqPrepareEnforcement.PROPERTY_FORK_BLOCK);
|
||||
}
|
||||
|
||||
private RoundChangeMessageValidator validator() {
|
||||
return new RoundChangeMessageValidator(
|
||||
payloadValidator,
|
||||
BftHelpers.calculateRequiredValidatorQuorum(VALIDATOR_COUNT),
|
||||
CHAIN_HEIGHT,
|
||||
validators.getNodeAddresses(),
|
||||
protocolSchedule);
|
||||
}
|
||||
|
||||
/** A round change with a prepared block and a justification made of UNSEALED PREPAREs. */
|
||||
private RoundChange roundChangeWithUnsealedJustification() {
|
||||
when(payloadValidator.validate(any())).thenReturn(true);
|
||||
when(blockValidator.validateBlock(any(), any()))
|
||||
.thenReturn(new ValidationResult(true, Optional.empty()));
|
||||
|
||||
final QbftBlockHeader header =
|
||||
new QbftBlockHeaderTestFixture().number(roundIdentifier.getSequenceNumber()).buildHeader();
|
||||
final QbftBlock block = new QbftBlockTestFixture().blockHeader(header).build();
|
||||
final PreparedCertificate prepCert =
|
||||
createPreparedCertificate(
|
||||
block, roundIdentifier, toArray(validators.getNodes(), QbftNode.class));
|
||||
return validators.getMessageFactory(0).createRoundChange(targetRound, Optional.of(prepCert));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// THE PAIR. The same message, once with the enforcement disarmed and once with it armed. A
|
||||
// different result means justifications really do pass through the enforcement; the same result
|
||||
// would mean the back door.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
@Test
|
||||
public void withoutEnforcementAnUNSEALEDJustificationIsVALID() {
|
||||
System.clearProperty(PqPrepareEnforcement.PROPERTY_FORK_BLOCK);
|
||||
assertThat(validator().validate(roundChangeWithUnsealedJustification())).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void withTheEnforcementARMEDTheSameJustificationISRefused() {
|
||||
System.setProperty(PqPrepareEnforcement.PROPERTY_FORK_BLOCK, "0");
|
||||
assertThat(validator().validate(roundChangeWithUnsealedJustification())).isFalse();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// The third state: the enforcement EXISTS but its height is in the future. Without this test, an
|
||||
// enforcement that refused regardless of height would pass as correct, and the binary could not
|
||||
// be rolled onto the fleet before the activation height.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
@Test
|
||||
public void withTheEnforcementBELOWItsHeightTheJustificationISVALID() {
|
||||
System.setProperty(
|
||||
PqPrepareEnforcement.PROPERTY_FORK_BLOCK, Long.toString(CHAIN_HEIGHT + 1L));
|
||||
assertThat(validator().validate(roundChangeWithUnsealedJustification())).isTrue();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// THE OTHER DIRECTION, and it is the very piece that keeps the way back open: a round change
|
||||
// WITHOUT a prepared block has no justification to validate, so it never touches the enforcement
|
||||
// at all. That explains why a chain stalled by the enforcement still advances its rounds
|
||||
// (measured, finding D-280), and it has to stay true: if it broke, the stall would no longer be
|
||||
// recoverable along that same road.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
@Test
|
||||
public void withTheEnforcementARMEDARoundChangeWITHOUTAPreparedBlockPASSES() {
|
||||
System.setProperty(PqPrepareEnforcement.PROPERTY_FORK_BLOCK, "0");
|
||||
when(payloadValidator.validate(any())).thenReturn(true);
|
||||
for (int i = 0; i < VALIDATOR_COUNT; i++) {
|
||||
final RoundChange without =
|
||||
validators.getMessageFactory(i).createRoundChange(targetRound, Optional.empty());
|
||||
assertThat(validator().validate(without)).isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================================
|
||||
// THE SECOND PATH, found 2026-08-29 by searching for EVERY place that builds a PrepareValidator
|
||||
// in production code, not just the one I happened to be looking at. There are three:
|
||||
// MessageValidator (ordinary PREPAREs), RoundChangeMessageValidator (the justification of a round
|
||||
// change) and ProposalValidator (the justification of a PROPOSAL for a new round).
|
||||
//
|
||||
// Without that search I would have reported "the coupling is guarded" with only one of the two
|
||||
// justification paths guarded - and the second one is precisely how a prepared block gets
|
||||
// RE-PROPOSED in a new round. The same back door, a different file.
|
||||
// =============================================================================================
|
||||
|
||||
private static final int INALTIME_PROPUNERE = 1;
|
||||
|
||||
private final ConsensusRoundIdentifier roundZero =
|
||||
new ConsensusRoundIdentifier(INALTIME_PROPUNERE, 0);
|
||||
private final ConsensusRoundIdentifier roundOne =
|
||||
new ConsensusRoundIdentifier(INALTIME_PROPUNERE, 1);
|
||||
|
||||
private QbftBlock blocPentru(final ConsensusRoundIdentifier rid, final int autor) {
|
||||
final QbftBlockHeader h =
|
||||
new QbftBlockHeaderTestFixture()
|
||||
.number(rid.getSequenceNumber())
|
||||
.coinbase(validators.getNode(autor).getAddress())
|
||||
.buildHeader();
|
||||
return new QbftBlockTestFixture().blockHeader(h).build();
|
||||
}
|
||||
|
||||
private ProposalValidator validatorulPropunerii() {
|
||||
return new ProposalValidator(
|
||||
blockInterface,
|
||||
protocolSchedule,
|
||||
BftHelpers.calculateRequiredValidatorQuorum(VALIDATOR_COUNT),
|
||||
validators.getNodeAddresses(),
|
||||
roundOne,
|
||||
proposerSelector);
|
||||
}
|
||||
|
||||
/**
|
||||
* A round-1 proposal that carries forward a block PREPARED in round 0, with the justification
|
||||
* made of UNSEALED PREPAREs. The scenario is the upstream one that passes; the only question from
|
||||
* here on is whether the enforcement changes it.
|
||||
*/
|
||||
private Proposal proposalWithUnsealedJustification() {
|
||||
lenient()
|
||||
.when(blockValidator.validateBlock(any(), any()))
|
||||
.thenReturn(new ValidationResult(true, Optional.empty()));
|
||||
lenient()
|
||||
.when(proposerSelector.selectProposerForRound(roundZero))
|
||||
.thenReturn(validators.getNode(0).getAddress());
|
||||
lenient()
|
||||
.when(proposerSelector.selectProposerForRound(roundOne))
|
||||
.thenReturn(validators.getNode(1).getAddress());
|
||||
|
||||
final QbftBlock blocRundaZero = blocPentru(roundZero, 0);
|
||||
final QbftBlock blocRundaUnu = blocPentru(roundOne, 1);
|
||||
|
||||
lenient()
|
||||
.when(
|
||||
blockInterface.replaceRoundAndProposerForProposalBlock(
|
||||
blocRundaUnu, 0, validators.getNode(0).getAddress()))
|
||||
.thenReturn(blocRundaZero);
|
||||
|
||||
final List<SignedData<RoundChangePayload>> schimbari =
|
||||
createEmptyRoundChangePayloads(roundOne, validators.getNode(0), validators.getNode(1));
|
||||
|
||||
final RoundChangePayload cuPregatit =
|
||||
new RoundChangePayload(
|
||||
roundOne,
|
||||
Optional.of(
|
||||
new PreparedRoundMetadata(blocRundaZero.getHash(), roundZero.getRoundNumber())));
|
||||
schimbari.add(
|
||||
SignedData.create(
|
||||
cuPregatit,
|
||||
validators
|
||||
.getNode(2)
|
||||
.getNodeKey()
|
||||
.sign(Bytes32.wrap(cuPregatit.hashForSignature().getBytes()))));
|
||||
|
||||
return validators
|
||||
.getMessageFactory(1)
|
||||
.createProposal(
|
||||
roundOne,
|
||||
blocRundaUnu,
|
||||
schimbari,
|
||||
createPreparePayloads(
|
||||
roundZero,
|
||||
blocRundaZero.getHash(),
|
||||
validators.getNode(0),
|
||||
validators.getNode(1),
|
||||
validators.getNode(2)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void withoutEnforcementAPROPOSALWithAnUNSEALEDJustificationIsVALID() {
|
||||
System.clearProperty(PqPrepareEnforcement.PROPERTY_FORK_BLOCK);
|
||||
assertThat(validatorulPropunerii().validate(proposalWithUnsealedJustification())).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void withTheEnforcementARMEDTheSamePROPOSALISRefused() {
|
||||
System.setProperty(PqPrepareEnforcement.PROPERTY_FORK_BLOCK, "0");
|
||||
assertThat(validatorulPropunerii().validate(proposalWithUnsealedJustification())).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void withTheEnforcementBELOWItsHeightThePROPOSALISVALID() {
|
||||
System.setProperty(
|
||||
PqPrepareEnforcement.PROPERTY_FORK_BLOCK, Long.toString(INALTIME_PROPUNERE + 1L));
|
||||
assertThat(validatorulPropunerii().validate(proposalWithUnsealedJustification())).isTrue();
|
||||
}
|
||||
}
|
||||
@ -28,7 +28,6 @@ import org.hyperledger.besu.consensus.common.bft.FalconSealSupport;
|
||||
import org.hyperledger.besu.consensus.common.bft.PqAnchorConfig;
|
||||
import org.hyperledger.besu.consensus.common.bft.headervalidationrules.BftCoinbaseValidationRule;
|
||||
import org.hyperledger.besu.consensus.common.bft.headervalidationrules.BftCommitSealsValidationRule;
|
||||
import org.hyperledger.besu.consensus.qbft.headervalidationrules.AereBaseFeeImportRule;
|
||||
import org.hyperledger.besu.consensus.qbft.headervalidationrules.FalconSealValidationRule;
|
||||
import org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorDigestAttachedRule;
|
||||
import org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorDigestRule;
|
||||
@ -103,11 +102,12 @@ public class QbftBlockHeaderValidationRulesetFactory {
|
||||
* therefore bit-for-bit equivalent to today's on the whole existing chain, which is the condition
|
||||
* for warming it on a live node.
|
||||
*
|
||||
* <p>The rule COUNT goes from 11 to 16 (A8 adds the registry-binding rule, was 13; SINCRONIZARE
|
||||
* adds the attached copy of the digest rule, was 14; OPTIUNI-URGENTA adds the emergency
|
||||
* announcement rule, which can never reject, was 15) (14 unconditional plus the conditional timestamp rule). The
|
||||
* design note said 12 because it assumed the legacy Falcon rule would be deleted; retiring it by
|
||||
* height instead is what keeps behaviour below H identical, so it stays in the list.
|
||||
* <p>The rule COUNT goes from 11 to 16 (the per-block registry binding adds one, was 13;
|
||||
* SINCRONIZARE adds the attached copy of the digest rule, was 14; OPTIUNI-URGENTA adds the
|
||||
* emergency announcement rule, which can never reject, was 15) (14 unconditional plus the
|
||||
* conditional timestamp rule). The original design note said 12 because it assumed the legacy
|
||||
* Falcon rule would be deleted; retiring it by height instead is what keeps behaviour below H
|
||||
* identical, so it stays in the list.
|
||||
*
|
||||
* @param minimumTimeBetweenBlocks the minimum amount of time that must elapse between blocks.
|
||||
* @param useValidatorContract whether validator selection is using a validator contract
|
||||
@ -143,16 +143,6 @@ public class QbftBlockHeaderValidationRulesetFactory {
|
||||
new GasLimitRangeAndDeltaValidationRule(
|
||||
DEFAULT_MIN_GAS_LIMIT, DEFAULT_MAX_GAS_LIMIT, baseFeeMarket))
|
||||
.addRule(new TimestampBoundedByFutureParameter(1))
|
||||
// AERE D-AMONTE-02: the base-fee rule every non-BFT factory in this jar wires and the
|
||||
// QBFT one upstream forgot. Height-gated (disarmed = today's behaviour, byte for byte):
|
||||
// the chain's HISTORY contains blocks this validation would reject (the two floor-less
|
||||
// days, the lost-threshold window where the fee was not a function of the parent), so
|
||||
// it must never look below its arming height. Delegates to THIS node's fee market, so
|
||||
// the AERE 1 Gwei floor is validated too - the check that closes the empty-block
|
||||
// one-wei divergence measured on the mixed network (STARE-PRODUCATOR 1bis).
|
||||
.addRule(
|
||||
new AereBaseFeeImportRule(
|
||||
AereBaseFeeImportRule.armedFromSystemConfig(), baseFeeMarket))
|
||||
.addRule(
|
||||
new ConstantFieldValidationRule<>(
|
||||
"MixHash", BlockHeader::getMixHash, BftHelpers.EXPECTED_MIX_HASH))
|
||||
@ -183,8 +173,8 @@ public class QbftBlockHeaderValidationRulesetFactory {
|
||||
.addRule(new PqAnchorDigestAttachedRule(pqAnchorConfig))
|
||||
// AERE ANCORA-V2 R2: attached, full validation only.
|
||||
.addRule(new PqAnchorSealsRule(pqAnchorConfig))
|
||||
// AERE A8 per-block half: the registry this node runs must be the registry
|
||||
// config.pqRegistryHash requires AT THIS HEIGHT. The startup guard answers
|
||||
// AERE REGISTRY BINDING, per-block half: the registry this node runs must be the
|
||||
// registry config.pqRegistryHash requires AT THIS HEIGHT. The startup guard answers
|
||||
// that once, against the head that existed at startup; a rotation entry in
|
||||
// the schedule can pass underneath a running node and never be noticed.
|
||||
// Inert when no schedule is configured, which is chain 2800 today.
|
||||
|
||||
@ -161,7 +161,7 @@ public class QbftBlockCreatorAdaptor implements QbftBlockCreator {
|
||||
final Hash commitHash =
|
||||
new BftBlockHashing(bftExtraDataCodec).calculateDataHashForCommittedSeal(sealedHeader);
|
||||
|
||||
// AERE audit fix (AUD-CONSENSUS-1 / -2): restrict the embedded certificate to ELIGIBLE
|
||||
// AERE audit fix, ELIGIBLE-SIGNER BINDING: restrict the embedded certificate to ELIGIBLE
|
||||
// signers (current validators carried in extraData INTERSECT the address-bound registry), so
|
||||
// the assembler never embeds a seal the header rule would later reject as ineligible.
|
||||
final Set<Address> registered = pqc.registeredValidatorAddresses();
|
||||
@ -172,9 +172,11 @@ public class QbftBlockCreatorAdaptor implements QbftBlockCreator {
|
||||
}
|
||||
}
|
||||
|
||||
// AERE DISC 2026-08-08: the interval gate. Measured on chain 2800 the same day: with all
|
||||
// seven validators attaching, this assembler wrote FIVE seals into EVERY header, 525 -> 3844
|
||||
// bytes, about 200 GB per node per year against 12 GB free on the tightest host.
|
||||
// AERE HEADER GROWTH 2026-08-08: the interval gate. Measured on chain 2800 the same day: with
|
||||
// every validator attaching, this assembler wrote FIVE seals into EVERY header, taking it from
|
||||
// 525 to 3844 bytes. That is roughly SEVEN TIMES the header bytes stored per block, on every
|
||||
// node, for as long as the chain runs, which is what makes the interval a design constraint
|
||||
// and not a tuning knob.
|
||||
//
|
||||
// The anchor producer has had an interval and a cap since 7 August. This assembler, the one
|
||||
// that runs BEFORE the activation height, had neither, so the controls were unreachable
|
||||
@ -203,10 +205,11 @@ public class QbftBlockCreatorAdaptor implements QbftBlockCreator {
|
||||
// can sign AND is itself an eligible signer, attach its own seal so a single-signer
|
||||
// certificate is still produced.
|
||||
if (quorumCert.isEmpty() && pqc.signingEnabled()) {
|
||||
// D2 (2026-08-06): this is the ONE registry question in the stack with no honest height -
|
||||
// "am I, right now, an eligible signer", asked before signing with the single private key
|
||||
// this process holds. It gets its own name rather than a fabricated height, so that no
|
||||
// future reader mistakes it for a verification path. See FalconSealSupport#localSigningAddress.
|
||||
// REGISTRY HEIGHT BINDING (2026-08-06): this is the ONE registry question in the stack with
|
||||
// no honest height - "am I, right now, an eligible signer", asked before signing with the
|
||||
// single private key this process holds. It gets its own name rather than a fabricated
|
||||
// height, so that no future reader mistakes it for a verification path. See
|
||||
// FalconSealSupport#localSigningAddress.
|
||||
final Address self = pqc.localSigningAddress();
|
||||
if (self != null && eligible.contains(self)) {
|
||||
// AERE FIX-OPRIRE-CONSENS (b): height-gated like every other attachment point.
|
||||
@ -246,11 +249,12 @@ public class QbftBlockCreatorAdaptor implements QbftBlockCreator {
|
||||
return new QbftBlockAdaptor(sealedBesuBlock);
|
||||
}
|
||||
|
||||
// D2 (2026-08-06): takes the height of the block being sealed. The seals gathered here are over
|
||||
// THIS block's committed-seal hash, so the height is this block's own and is known at the call
|
||||
// site. It matters at exactly one moment - a rotation height - where assembling a certificate
|
||||
// under the head key set while every validator checks it under the scheduled one produces a block
|
||||
// the fleet rejects, with nothing in any log naming the reason.
|
||||
// REGISTRY HEIGHT BINDING (2026-08-06): takes the height of the block being sealed. The seals
|
||||
// gathered here are over THIS block's committed-seal hash, so the height is this block's own and
|
||||
// is known at the call site. It matters at exactly one moment - a rotation height - where
|
||||
// assembling a certificate under the head key set while every validator checks it under the
|
||||
// scheduled one produces a block every other node rejects, with nothing in any log naming the
|
||||
// reason.
|
||||
private static List<FalconSeal> verifiedDistinctSeals(
|
||||
final FalconSealSupport pqc,
|
||||
final long blockNumber,
|
||||
@ -275,7 +279,7 @@ public class QbftBlockCreatorAdaptor implements QbftBlockCreator {
|
||||
}
|
||||
// Bind each seal to its registered validator address and keep it only if that address is an
|
||||
// eligible signer (a current validator with a registered key), de-duplicated by address.
|
||||
// D2 (b-v2): the OWN-HEAD door. This is the block this node is sealing right now.
|
||||
// REGISTRY HEIGHT BINDING, the OWN-HEAD door: this is the block this node is sealing now.
|
||||
final Address signer = pqc.addressForIndexAtOwnHead(blockNumber, seal.getValidatorIndex());
|
||||
if (signer == null || !eligible.contains(signer) || seen.contains(signer)) {
|
||||
continue;
|
||||
|
||||
@ -1,135 +0,0 @@
|
||||
/*
|
||||
* AERE D-AMONTE-02 (found 2026-07-17, built 2026-08-25): base-fee enforcement at QBFT block
|
||||
* IMPORT, armed by height.
|
||||
*
|
||||
* WHY IT EXISTS. The upstream QBFT factory does not contain
|
||||
* BaseFeeMarketBlockHeaderGasPriceValidationRule, which the clique, merge and mainnet
|
||||
* factories in the SAME jar all name. Besu catches a wrong fee only indirectly, by
|
||||
* re-executing the body (a different state root); an EMPTY block has no body for that defence
|
||||
* to bite into, and 98.3% of chain 2800's blocks are empty. Measured on mixed network 91777
|
||||
* (STARE-PRODUCATOR-2026-08-02.md, 1bis): a single validator proposing an empty header with
|
||||
* the fee wrong by ONE WEI permanently detaches client 2 (which validates correctly, as a
|
||||
* pure function of the parent), while the Besu quorum makes it canonical and nothing shouts.
|
||||
* Our own 1 Gwei floor is itself unenforced at import for empty blocks.
|
||||
*
|
||||
* WHY BY HEIGHT, AND NEVER OVER HISTORY. Chain 2800's history CONTAINS blocks that fail this
|
||||
* validation: for two days (2026-08-09..11) the fleet ran with the floor fork LOST and wrote
|
||||
* fees below the floor; and inside the lost-threshold window (around 12,978,617) the fee is
|
||||
* NOT a function of the parent but of the validator that won the round. A rule not armed by
|
||||
* height would reject those blocks on every resync and break the chain. That is why below
|
||||
* the arming height the rule returns true as its FIRST statement, before any computation.
|
||||
*
|
||||
* WHY IT DELEGATES TO THE FEE MARKET INSTEAD OF RECOMPUTING. LondonFeeMarket in this tree
|
||||
* applies the AERE floor in computeBaseFee on ALL paths, so the upstream rule, fed with the
|
||||
* node's fee market, validates exactly the FLOORED fee producers write. One source of truth,
|
||||
* not two: if the floor ever changes, validation follows it by itself.
|
||||
*
|
||||
* CONFIG. -Daere.basefee.validate.forkBlock=<H> (env AERE_BASEFEE_VALIDATE_FORKBLOCK).
|
||||
* Absent = DISARMED (today's behaviour, byte for byte). A broken value = loud refusal
|
||||
* AERE-BASEFEE-VALIDATE-CONF-01 at factory construction, i.e. at node startup, never a
|
||||
* silent disarm. There is no consensus binding on the value: the fleet coordinates on it
|
||||
* exactly as on the anchor heights. REGISTRY ORDER: first walk the history on the archive
|
||||
* node (~1.77M unmeasured blocks), only then choose H; activation is the founder's.
|
||||
*/
|
||||
package org.hyperledger.besu.consensus.qbft.headervalidationrules;
|
||||
|
||||
import org.hyperledger.besu.ethereum.core.BlockHeader;
|
||||
import org.hyperledger.besu.ethereum.mainnet.DetachedBlockHeaderValidationRule;
|
||||
import org.hyperledger.besu.ethereum.mainnet.feemarket.BaseFeeMarket;
|
||||
import org.hyperledger.besu.ethereum.mainnet.headervalidationrules.BaseFeeMarketBlockHeaderGasPriceValidationRule;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/** Height-gated base-fee validation at QBFT import: the missing rule, armed only above H. */
|
||||
public class AereBaseFeeImportRule implements DetachedBlockHeaderValidationRule {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(AereBaseFeeImportRule.class);
|
||||
|
||||
/** The disarmed height: no block ever reaches it, upstream behaviour everywhere. */
|
||||
public static final long DISARMED = Long.MAX_VALUE;
|
||||
|
||||
/** System property naming the first height at which the rule bites. Absent = disarmed. */
|
||||
public static final String PROPERTY_FORK_BLOCK = "aere.basefee.validate.forkBlock";
|
||||
|
||||
/** Environment fallback for {@link #PROPERTY_FORK_BLOCK}. */
|
||||
public static final String ENV_FORK_BLOCK = "AERE_BASEFEE_VALIDATE_FORKBLOCK";
|
||||
|
||||
private final long armedFromBlock;
|
||||
private final BaseFeeMarketBlockHeaderGasPriceValidationRule delegate;
|
||||
|
||||
/**
|
||||
* @param armedFromBlock first height (inclusive) at which the rule bites; {@link #DISARMED}
|
||||
* for today's behaviour
|
||||
* @param baseFeeMarket the fee market THIS NODE runs (carries the AERE floor fork), empty on
|
||||
* a pre-London chain
|
||||
* @throws IllegalStateException AERE-BASEFEE-VALIDATE-CONF-02 when armed without a fee market:
|
||||
* an armed rule with nothing to compute against must refuse at startup, not skip silently
|
||||
*/
|
||||
public AereBaseFeeImportRule(
|
||||
final long armedFromBlock, final Optional<BaseFeeMarket> baseFeeMarket) {
|
||||
this.armedFromBlock = armedFromBlock;
|
||||
if (armedFromBlock != DISARMED && baseFeeMarket.isEmpty()) {
|
||||
throw new IllegalStateException(
|
||||
"AERE-BASEFEE-VALIDATE-CONF-02: " + PROPERTY_FORK_BLOCK + " is armed at "
|
||||
+ armedFromBlock + " but this chain has no base-fee market to validate against."
|
||||
+ " An armed rule must refuse at startup, never skip silently.");
|
||||
}
|
||||
this.delegate =
|
||||
baseFeeMarket.map(BaseFeeMarketBlockHeaderGasPriceValidationRule::new).orElse(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* The arming height the production factory wires in, read from system configuration.
|
||||
*
|
||||
* @return the height, or {@link #DISARMED} when the property is not set anywhere
|
||||
* @throws IllegalStateException AERE-BASEFEE-VALIDATE-CONF-01 on a present but unparseable
|
||||
* value; the factory runs at node startup, so the refusal lands at config time
|
||||
*/
|
||||
public static long armedFromSystemConfig() {
|
||||
String raw = System.getProperty(PROPERTY_FORK_BLOCK);
|
||||
if (raw == null) {
|
||||
raw = System.getenv(ENV_FORK_BLOCK);
|
||||
}
|
||||
if (raw == null) {
|
||||
return DISARMED;
|
||||
}
|
||||
try {
|
||||
final long h = Long.parseLong(raw.trim());
|
||||
if (h < 0) {
|
||||
throw new NumberFormatException("negative");
|
||||
}
|
||||
return h;
|
||||
} catch (final NumberFormatException e) {
|
||||
throw new IllegalStateException(
|
||||
"AERE-BASEFEE-VALIDATE-CONF-01: " + PROPERTY_FORK_BLOCK
|
||||
+ " is set but not a non-negative block height: '" + raw
|
||||
+ "'. A mistyped value must refuse, never silently disarm.");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean validate(final BlockHeader header, final BlockHeader parent) {
|
||||
// History stays untouched: below H this rule does not exist, first statement, no compute.
|
||||
if (header.getNumber() < armedFromBlock) {
|
||||
return true;
|
||||
}
|
||||
final boolean ok = delegate.validate(header, parent);
|
||||
if (!ok) {
|
||||
LOG.info(
|
||||
"AERE BASEFEE-VALIDATE: header {} carries a base fee the fee market of this node"
|
||||
+ " (floor included) does not reproduce from its parent - rejected at import",
|
||||
header.getNumber());
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean includeInLightValidation() {
|
||||
// Same stance as the anchor digest rule: cheap, stateless, and exactly the check a
|
||||
// header-syncing node can and should make.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright contributors to Besu / AERE Network.
|
||||
* Copyright contributors to Besu / Aere Network.
|
||||
*
|
||||
* 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
|
||||
@ -43,27 +43,12 @@ import org.slf4j.LoggerFactory;
|
||||
/**
|
||||
* Verifies the PARALLEL Falcon-512 post-quantum QUORUM CERTIFICATE embedded in a QBFT block header.
|
||||
*
|
||||
* <p><b>RETIRED ON CHAIN 2800, AND EVERYTHING BELOW DESCRIBES A RULE THAT NO LONGER APPLIES THERE
|
||||
* (finding D-235, corrected 2026-08-19).</b> This rule stands down at
|
||||
* {@code PqAnchorConfig.legacyFalconRuleRetirementBlock()}, which is the anchor block itself
|
||||
* ({@code everActive() ? anchorBlock : NEVER}). On chain 2800 the anchor block is 13,014,000 and
|
||||
* {@code aere.falcon.forkBlock} is 14,050,000 - the arming height is ABOVE the retirement height,
|
||||
* so this rule has never once been in force there, and arming that property changes nothing. What
|
||||
* actually carries the post-quantum verdict on 2800 is the pair of V2 anchor rules: at every 32nd
|
||||
* height, a certificate of at least K valid Falcon-512 seals under the block hash.
|
||||
*
|
||||
* <p>The text below is kept because the rule is real code and can be armed on a chain that never
|
||||
* reached an anchor block; it is not kept as a description of 2800. Until 2026-08-19 the site, the
|
||||
* whitepaper and seven press releases said a per-block 2f+1 Falcon quorum had been blocking since
|
||||
* 14,050,000. That claim was withdrawn in public the same day, and the withdrawal is the reason
|
||||
* this paragraph exists: an auditor reading the code must not find here the claim we retracted.
|
||||
*
|
||||
* <p>A Falcon quorum certificate is the set of Falcon-512 seals gossiped by validators on their QBFT
|
||||
* commit messages (each a signature over the same commit hash the ECDSA committed seal signs),
|
||||
* aggregated by the block assembler into the header's parallel Falcon-seal list.
|
||||
*
|
||||
* <p><b>AERE audit fix (AUD-CONSENSUS-1 / AUD-CONSENSUS-2, 2026-07-18).</b> Both the Falcon quorum
|
||||
* threshold AND the counted-seal set are now bound to ONE well-defined set:
|
||||
* <p><b>AERE audit fix, ELIGIBLE-SIGNER BINDING (2026-07-18).</b> Both the Falcon quorum threshold
|
||||
* AND the counted-seal set are now bound to ONE well-defined set:
|
||||
*
|
||||
* <pre>
|
||||
* eligibleSigners = currentValidators (getValidatorsAfterBlock(parent))
|
||||
@ -102,12 +87,13 @@ import org.slf4j.LoggerFactory;
|
||||
* boundary, i.e. arm time), NEVER an implicit accept.
|
||||
* </ul>
|
||||
*
|
||||
* <p>ARMING INVARIANT (AUD-CONSENSUS-1): blocking should be armed only when the registry COVERS the
|
||||
* validator set (every current validator has a Falcon key), so that {@code eligibleSigners ==
|
||||
* currentValidators} and the Falcon quorum equals the ECDSA quorum with full fault margin. When
|
||||
* coverage is incomplete the rule stays LIVE on the intersection (it does not halt) but logs a LOUD
|
||||
* warning that the margin is reduced and a registry re-anchor is required. This is the "either the
|
||||
* intersection keeps it live, or it fail-closes at arm time, never as a silent halt" contract.
|
||||
* <p>ARMING INVARIANT (eligible-signer binding): blocking should be armed only when the registry
|
||||
* COVERS the validator set (every current validator has a Falcon key), so that {@code
|
||||
* eligibleSigners == currentValidators} and the Falcon quorum equals the ECDSA quorum with full
|
||||
* fault margin. When coverage is incomplete the rule stays LIVE on the intersection (it does not
|
||||
* halt) but logs a LOUD warning that the margin is reduced and a registry re-anchor is required.
|
||||
* This is the "either the intersection keeps it live, or it fail-closes at arm time, never as a
|
||||
* silent halt" contract.
|
||||
*
|
||||
* <p>STAGE-2 LATE-ANCHOR activation: when the node is configured with a late-anchor manifest
|
||||
* ({@code aere.falcon.manifest} + {@code aere.falcon.anchor.address}) on a chain that launched
|
||||
@ -127,8 +113,9 @@ public class FalconSealValidationRule implements AttachedBlockHeaderValidationRu
|
||||
* <p>WHY THIS EXISTS, measured on a live node. The summary was written at INFO on EVERY imported
|
||||
* block. On chain 2800 at ~523 ms per block that is <b>2295 lines in twenty minutes, about 165.000
|
||||
* a day per node</b>, and every one of them said the same thing: {@code 0 of 0 seals,
|
||||
* |eligible|=0, no-eligible-signers}. A line that cannot change carries no information, and seven
|
||||
* validators had just come out of a disk emergency.
|
||||
* |eligible|=0, no-eligible-signers}. A line that cannot change carries no information, and a log
|
||||
* that repeats one at that rate is a log an operator stops reading, which is how a real error
|
||||
* gets missed.
|
||||
*
|
||||
* <p>WHAT IS KEPT. Every CHANGE of outcome still logs at INFO immediately, so an operator sees the
|
||||
* transition into and out of quorum on the block it happens. Unchanged state logs once per
|
||||
@ -195,14 +182,14 @@ public class FalconSealValidationRule implements AttachedBlockHeaderValidationRu
|
||||
* @return true to log at INFO, false to drop to DEBUG
|
||||
*/
|
||||
boolean shouldLogAtInfo(final String rezumat, final long blockNumber) {
|
||||
final boolean hasChanged = !rezumat.equals(lastLoggedOutcome);
|
||||
final boolean seSchimba = !rezumat.equals(lastLoggedOutcome);
|
||||
// Long.MIN_VALUE as "never logged" cannot be subtracted from without overflowing, and an
|
||||
// overflow here would silently invert the comparison: the first block would take the DEBUG
|
||||
// branch and the very first line, the one that tells an operator the rule is alive at all,
|
||||
// would never appear.
|
||||
final boolean bataieDeInima =
|
||||
lastLoggedBlock == Long.MIN_VALUE || blockNumber - lastLoggedBlock >= LOG_HEARTBEAT_BLOCKS;
|
||||
if (hasChanged || bataieDeInima) {
|
||||
if (seSchimba || bataieDeInima) {
|
||||
lastLoggedOutcome = rezumat;
|
||||
lastLoggedBlock = blockNumber;
|
||||
return true;
|
||||
@ -245,7 +232,7 @@ public class FalconSealValidationRule implements AttachedBlockHeaderValidationRu
|
||||
// BLOCKING was simply never armed and the whole PQC layer degraded to LOG-ONLY - it failed OPEN
|
||||
// on exactly the input an attacker controls. FAILED now fails CLOSED at and after the fork
|
||||
// block, while PENDING keeps the deliberate log-only behaviour that lets a legitimate anchor
|
||||
// transaction still land (see AUD-CONSENSUS-4 below).
|
||||
// transaction still land (see the ARMING PRECONDITION note below).
|
||||
final long forkBlock = pqc.forkBlock();
|
||||
if (header.getNumber() >= forkBlock && pqc.lateAnchorFailed()) {
|
||||
LOG.error(
|
||||
@ -259,8 +246,8 @@ public class FalconSealValidationRule implements AttachedBlockHeaderValidationRu
|
||||
pqc.anchorAddress());
|
||||
return false;
|
||||
}
|
||||
// AUD-CONSENSUS-4: entering BLOCKING mode requires BOTH the fork height AND an ACTIVE anchored
|
||||
// registry (genesis-anchored, or a late anchor already activated by tryActivateLateAnchor
|
||||
// ARMING PRECONDITION: entering BLOCKING mode requires BOTH the fork height AND an ACTIVE
|
||||
// anchored registry (genesis-anchored, or a late anchor already activated by tryActivateLateAnchor
|
||||
// above). If forkBlock is armed at or before the late-anchor observation height, the registry is
|
||||
// not yet active when the fork block is validated; blocking there rejects every block (empty
|
||||
// registry => no eligible seal) and permanently HALTS the chain before the anchor-deploy
|
||||
@ -271,12 +258,12 @@ public class FalconSealValidationRule implements AttachedBlockHeaderValidationRu
|
||||
final boolean forkReached = header.getNumber() >= forkBlock;
|
||||
final boolean registryActive = pqc.genesisAnchored() || pqc.lateAnchored();
|
||||
final boolean blocking = forkReached && registryActive;
|
||||
// AERE D-079: leave a MARK, not only a line. The log-only answer below is the right answer for a
|
||||
// header rule, and it is also how this condition used to vanish: the node was configured to
|
||||
// enforce a post-quantum quorum, enforced nothing, and said so once per block into a file. The
|
||||
// counter is readable from a test and from a JMX/diagnostic path; the WARN is emitted only on
|
||||
// the first occurrence, because one line per block at a sub-second block period is itself a
|
||||
// hazard on this fleet.
|
||||
// ARMED WITHOUT AN ACTIVE REGISTRY: leave a MARK, not only a line. The log-only answer below is
|
||||
// the right answer for a header rule, and it is also how this condition used to vanish: the node
|
||||
// was configured to enforce a post-quantum quorum, enforced nothing, and said so once per block
|
||||
// into a file. The counter is readable from a test and from a JMX/diagnostic path; the WARN is
|
||||
// emitted only on the first occurrence, because one line per block at a sub-second block period
|
||||
// is itself a hazard.
|
||||
if (forkReached
|
||||
&& !registryActive
|
||||
&& pqc.noteBlockingArmedWithoutActiveRegistry(header.getNumber())) {
|
||||
@ -308,7 +295,7 @@ public class FalconSealValidationRule implements AttachedBlockHeaderValidationRu
|
||||
// The gate refuses to attach until the anchored registry covers ALL of these addresses.
|
||||
pqc.observeValidators(header.getNumber(), validators);
|
||||
|
||||
// AERE audit fix (AUD-CONSENSUS-1 / -2): the eligible-signer set is the intersection of the
|
||||
// AERE audit fix, ELIGIBLE-SIGNER BINDING: the eligible-signer set is the intersection of the
|
||||
// CURRENT validator set with the address-bound signer registry. BOTH the quorum and the
|
||||
// counted-seal set are derived from this ONE set, so neither can drift from the other.
|
||||
final Set<Address> registered = pqc.registeredValidatorAddresses();
|
||||
@ -327,17 +314,18 @@ public class FalconSealValidationRule implements AttachedBlockHeaderValidationRu
|
||||
// A seal counts only if its registry-bound address is an eligible signer (i.e. a current
|
||||
// validator with a registered key); seals from registered-but-removed validators, or from
|
||||
// unregistered indices, are excluded.
|
||||
// D2 (2026-08-06): resolve the key set AT THE HEIGHT OF THE HEADER CARRYING THE SEAL, not at
|
||||
// this node's head. These seals are over THIS header's committed-seal hash, so the height is
|
||||
// this header's own - unlike R2, whose certificate commits to the PARENT. The adversarial
|
||||
// review measured this rule asking a height-less registry; below the arming height the
|
||||
// resolver still answers from the head registry, so the 11.8 million blocks already on chain
|
||||
// 2800 are checked exactly as before, but the rule can no longer be the reason a rotation
|
||||
// makes history unverifiable.
|
||||
// REGISTRY HEIGHT BINDING (2026-08-06): resolve the key set AT THE HEIGHT OF THE HEADER
|
||||
// CARRYING THE SEAL, not at this node's head. These seals are over THIS header's committed-seal
|
||||
// hash, so the height is this header's own - unlike R2, whose certificate commits to the
|
||||
// PARENT. The adversarial review measured this rule asking a height-less registry; below the
|
||||
// arming height the resolver still answers from the head registry, so the 11.8 million blocks
|
||||
// already on chain 2800 are checked exactly as before, but the rule can no longer be the
|
||||
// reason a rotation makes history unverifiable.
|
||||
final Set<Address> counted = new HashSet<>();
|
||||
for (final FalconSeal seal : falconSeals) {
|
||||
// D2 (b-v2): the HISTORY door. R1's seals are over THIS header's committed-seal hash,
|
||||
// so the height is the header's own; the header still came from outside.
|
||||
// REGISTRY HEIGHT BINDING, the HISTORY door: R1's seals are over THIS header's
|
||||
// committed-seal hash, so the height is the header's own; the header still came from
|
||||
// outside.
|
||||
final Address signer =
|
||||
pqc.addressForIndexAtHistoric(header.getNumber(), seal.getValidatorIndex());
|
||||
if (signer == null || !eligible.contains(signer) || counted.contains(signer)) {
|
||||
@ -386,7 +374,7 @@ public class FalconSealValidationRule implements AttachedBlockHeaderValidationRu
|
||||
+ "(|eligible|={} < N={}). Running on the eligible intersection (LIVE, quorum={}), "
|
||||
+ "but two-fault liveness margin is reduced. A validator was added without an "
|
||||
+ "atomic registry re-anchor: RE-ANCHOR the Falcon manifest for the full validator "
|
||||
+ "set (see PQ-CONSENSUS-LIVE-READINESS validator-expansion procedure).",
|
||||
+ "set, in the same change that adds the validator, never as a later step.",
|
||||
header.getNumber(),
|
||||
eligible.size(),
|
||||
validators.size(),
|
||||
@ -418,15 +406,15 @@ public class FalconSealValidationRule implements AttachedBlockHeaderValidationRu
|
||||
}
|
||||
|
||||
// Pre-fork: log-only, never blocks.
|
||||
final String state =
|
||||
final String stare =
|
||||
eligible.isEmpty()
|
||||
? "no-eligible-signers"
|
||||
: (valid >= quorum ? "PQC-QUORUM-MET" : "PQC-quorum-not-yet");
|
||||
// The verdict is already decided above. Everything below only picks a LOG LEVEL.
|
||||
final String rezumat =
|
||||
state + "|" + valid + "|" + falconSeals.size() + "|" + eligible.size() + "|" + validators.size();
|
||||
stare + "|" + valid + "|" + falconSeals.size() + "|" + eligible.size() + "|" + validators.size();
|
||||
final boolean laInfo = shouldLogAtInfo(rezumat, header.getNumber());
|
||||
final String message =
|
||||
final String mesaj =
|
||||
"AERE PQC (LOG-ONLY): block {} -> {} of {} Falcon seal(s) verified over |eligible|={} "
|
||||
+ "(N={}); 2/3 eligible quorum would be {} [{}]. This check never blocks pre-fork; "
|
||||
+ "ECDSA committed seals remain decisive.";
|
||||
@ -434,24 +422,24 @@ public class FalconSealValidationRule implements AttachedBlockHeaderValidationRu
|
||||
lastLoggedOutcome = rezumat;
|
||||
lastLoggedBlock = header.getNumber();
|
||||
LOG.info(
|
||||
message,
|
||||
mesaj,
|
||||
header.getNumber(),
|
||||
valid,
|
||||
falconSeals.size(),
|
||||
eligible.size(),
|
||||
validators.size(),
|
||||
quorum,
|
||||
state);
|
||||
stare);
|
||||
} else {
|
||||
LOG.debug(
|
||||
message,
|
||||
mesaj,
|
||||
header.getNumber(),
|
||||
valid,
|
||||
falconSeals.size(),
|
||||
eligible.size(),
|
||||
validators.size(),
|
||||
quorum,
|
||||
state);
|
||||
stare);
|
||||
}
|
||||
return true;
|
||||
} catch (final Exception e) {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright contributors to Besu / AERE Network.
|
||||
* Copyright contributors to Besu / Aere Network.
|
||||
*
|
||||
* 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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user