Bring the published anchor overlay level with the tree we run, and say which layers are armed
The first publication of this directory, on 11 August 2026, was staged by hand and was never refreshed. Measured on 29 August: of the 78 files published here, only 14 were still identical to the tree we run, 63 had changed, and 50 files had never been published at all. Nobody was hiding anything; there was simply no tool that could redo the selection, so nobody redid it. There is one now, and it derives this directory from the authoritative overlay rather than from anyone's memory. Two things in here contradicted themselves, and both are fixed rather than trimmed. The README withdrew, in one section, a claim that the source next to it still made: that from block 14,050,000 a per-block 2f+1 Falcon quorum was required for a block to finalize. The rule that property arms is retired at the anchor block, so it was inert on arrival. Three places in the code said otherwise and now carry the correction with its date. The old text is kept, not deleted: the rule is real code and can be armed on a chain that never reached an anchor block. What was missing was that on chain 2800 it does not apply. The terminology section said the certificate is signed by f+1 validators and "not by a quorum", and invited the reader to count. Since 14,961,456 the enforced minimum is six of nine, which is a quorum, and the paragraph had not been revised. It now says so, with the date it changed, and adds the two things that make the claim defensible: the word applies only at anchor heights, and the higher threshold costs liveness margin. New in this directory: the post-quantum seal on PREPARE, the hybrid Falcon + SLH-DSA scheme layer, and the test suites for both. Neither is armed on any network, and the README now carries a table saying which layers are armed on chain 2800 and which are only present. They are disarmed by absence rather than by a flag: the properties that arm them are unset, and unset means never. Not published, and it is the same three files every time: the negative-control harnesses. They plant a defect on purpose to prove a guard can fail, which makes them a recipe for disabling a guard rather than a description of one. What they prove is stated in the README. Per-file SHA-256 in MANIFEST-sha256.txt. 1,012 tests, 0 failures, counted from the XML.
This commit is contained in:
parent
249da4ff7c
commit
a7b24b8f30
134
anchor/MANIFEST-sha256.txt
Normal file
134
anchor/MANIFEST-sha256.txt
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
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,7 +52,8 @@ 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"
|
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,
|
(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
|
because we checked it rather than repeated it. **Winkle does not mention quantum adversaries at
|
||||||
all**: it treats old validator keys becoming compromised, by any means. The quantum framing is
|
all**: it treats the case where a validator's old signing keys reach an adversary, by any
|
||||||
|
means. The quantum framing is
|
||||||
ours. And **Winkle's own defence is not ours**: it adds a secondary layer of client-based
|
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
|
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
|
for the same threat, Azouvi and Vukolic, "Pikachu: Securing PoS Blockchains from
|
||||||
@ -122,6 +123,45 @@ now pinned: change one byte of it and the digest no longer matches, and the head
|
|||||||
|
|
||||||
## What is in here
|
## 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
|
- `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.
|
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
|
- `consensus/qbft/.../headervalidationrules/` — the validation rules: the digest must match, the
|
||||||
@ -173,6 +213,14 @@ and the validation rules.
|
|||||||
upgraded every node.
|
upgraded every node.
|
||||||
- **Nothing here demonstrates what is configured on any live network.** These files show what the
|
- **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.
|
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
|
## One claim we retracted, on purpose
|
||||||
|
|
||||||
|
|||||||
@ -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
|
* 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
|
* the License. You may obtain a copy of the License at
|
||||||
@ -32,10 +32,9 @@ import picocli.CommandLine;
|
|||||||
* If the anchor misbehaves on the live chain at three in the morning, the person on the other end of
|
* 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
|
* 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
|
* were real but reachable only as system properties and environment variables, which in practice
|
||||||
* means editing a service unit or a wrapper script on every node of the fleet under time pressure,
|
* means editing a systemd unit or a wrapper script on seven machines under time pressure, in a file
|
||||||
* in a file whose syntax nobody remembers, with no {@code --help} to check against. Two of these
|
* whose syntax nobody remembers, with no {@code --help} to check against. Two of these options were
|
||||||
* options were already named in the javadoc of {@code PqAnchorConfig} as though they existed. They
|
* already named in the javadoc of {@code PqAnchorConfig} as though they existed. They did not.
|
||||||
* did not.
|
|
||||||
*
|
*
|
||||||
* <p><b>The three controls, and why exactly these three.</b>
|
* <p><b>The three controls, and why exactly these three.</b>
|
||||||
*
|
*
|
||||||
@ -49,10 +48,10 @@ import picocli.CommandLine;
|
|||||||
* one to reach for when the chain has stalled because proposers cannot gather K seals - too
|
* 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.
|
* 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
|
* <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
|
* registry that does not satisfy what genesis requires. This is the way back from the A8
|
||||||
* REGISTRY BINDING guard, which is a CONFIGURATION guard: one wrong byte in a registry file
|
* guard, which is a CONFIGURATION guard: one wrong byte in a registry file pushed to the fleet
|
||||||
* pushed to the fleet stops every node it reached, for a reason that has nothing to do with
|
* stops every node it reached, for a reason that has nothing to do with whether the blocks are
|
||||||
* whether the blocks are valid.
|
* valid.
|
||||||
* </ul>
|
* </ul>
|
||||||
*
|
*
|
||||||
* <p><b>Every one of them shouts.</b> A quiet way out is worse than no way out, because it will be
|
* <p><b>Every one of them shouts.</b> A quiet way out is worse than no way out, because it will be
|
||||||
@ -65,10 +64,10 @@ import picocli.CommandLine;
|
|||||||
* <p><b>How they take effect, and why through the properties.</b> Each option writes the SAME system
|
* <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
|
* 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
|
* 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 the registry
|
* different from the environment variable, and the code that was measured under A8 and under the
|
||||||
* binding work and under the anchor work is the code still doing the deciding. Precedence is
|
* anchor work is the code still doing the deciding. Precedence is command line, then system
|
||||||
* command line, then system property, then environment variable; which source won is written into
|
* property, then environment variable; which source won is written into the log so an operator never
|
||||||
* the log so an operator never has to guess whether the flag took.
|
* has to guess whether the flag took.
|
||||||
*
|
*
|
||||||
* <p><b>Deliberately LOCAL, not on-chain.</b> A halted chain cannot deliver a height-scheduled
|
* <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
|
* configuration change. The only control that works when the chain is ALREADY STOPPED is one that
|
||||||
|
|||||||
@ -264,7 +264,8 @@ public class QbftBesuControllerBuilder extends BesuControllerBuilder {
|
|||||||
// eligible Falcon seal(s) ... threshold is 3", over "Attachment stays OFF (fail-safe)".
|
// 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.
|
// With K=0 the chain heals itself. With K>0 it NEVER heals.
|
||||||
//
|
//
|
||||||
// A simultaneous restart of the seven is not an exotic scenario: it is a power cut, a scheduled
|
// A simultaneous restart of the whole fleet is not an exotic scenario: it is a power cut, a
|
||||||
|
// scheduled
|
||||||
// kernel update, or any procedure that starts the fleet all at once.
|
// 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
|
// The repair invents nothing and weakens no check: it does here, once, exactly what the import
|
||||||
@ -310,7 +311,7 @@ public class QbftBesuControllerBuilder extends BesuControllerBuilder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// AERE REGISTRY-BINDING (2026-08-01): bind the Falcon registry to consensus.
|
// AERE A8 (2026-08-01): bind the Falcon registry to consensus.
|
||||||
//
|
//
|
||||||
// Deliberately placed immediately after the attachment guard and before BftExecutors, for the
|
// 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
|
// same reason: a chain head exists here, and the network and the QBFT state machine have not
|
||||||
@ -321,13 +322,13 @@ public class QbftBesuControllerBuilder extends BesuControllerBuilder {
|
|||||||
// The two guards answer different questions - "is the activation height sane relative to this
|
// 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.
|
// chain" and "is this the registry this chain requires" - and both have to be true.
|
||||||
//
|
//
|
||||||
// AERE REGISTRY-BINDING (2026-08-02): the SCHEDULE comes from the genesis configuration BESU
|
// AERE A8 (2026-08-02): the SCHEDULE comes from the genesis configuration BESU BOOTED WITH, not
|
||||||
// BOOTED WITH, not from a genesis file re-opened by path from a system property. Re-reading a
|
// from a genesis file re-opened by path from a system property. Re-reading a file would have
|
||||||
// file would have reproduced the defect one level up: the enforced binding would again depend
|
// reproduced the defect one level up: the enforced binding would again depend on a local file a
|
||||||
// on a local file a node can be pointed at wrongly, and a node reading a stale copy would
|
// node can be pointed at wrongly, and a node reading a stale copy would enforce a stale
|
||||||
// enforce a stale schedule, or none, in silence. Read from GenesisConfigOptions there is no
|
// schedule, or none, in silence. Read from GenesisConfigOptions there is no second file: the
|
||||||
// second file: the value enforced comes out of the same object that produced this node's
|
// value enforced comes out of the same object that produced this node's genesis hash, so a node
|
||||||
// genesis hash, so a node that disagrees about the schedule already disagrees about the chain.
|
// 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
|
// 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
|
// config.chainId = 2800, so getChainId() is PRESENT and the 0L fallback below is not the value
|
||||||
@ -378,13 +379,12 @@ public class QbftBesuControllerBuilder extends BesuControllerBuilder {
|
|||||||
// with zero seals, nobody reached K, nobody could propose, and so nobody sent another Commit.
|
// with zero seals, nobody reached K, nobody could propose, and so nobody sent another Commit.
|
||||||
// The same circular deadlock, one level down.
|
// The same circular deadlock, one level down.
|
||||||
//
|
//
|
||||||
// WHY THIS IS NOT A REGISTRY-TRUSTED-FROM-A-FILE IN NEW CLOTHES, and this is the whole security
|
// WHY THIS IS NOT A8 IN NEW CLOTHES, and this is the whole security argument: the seal is
|
||||||
// argument: the seal is SELF-VERIFYING. Every seal read from the file is cryptographically
|
// SELF-VERIFYING. Every seal read from the file is cryptographically verified again against the
|
||||||
// verified again against the anchored registry, over an M rebuilt from the head header this very
|
// anchored registry, over an M rebuilt from the head header this very process has just loaded,
|
||||||
// process has just loaded, exactly as the producer does at selection time. A forged file cannot
|
// exactly as the producer does at selection time. A forged file cannot inject a seal without
|
||||||
// inject a seal without forging a Falcon-512 signature; all it can obtain is the empty cache an
|
// forging a Falcon-512 signature; all it can obtain is the empty cache an absent file already
|
||||||
// absent file already gives. The defect back then was a REGISTRY of keys trusted because it sat
|
// gives. A8 was a REGISTRY of keys trusted because it sat in a file.
|
||||||
// in a file.
|
|
||||||
//
|
//
|
||||||
// Deliberately here: the registry is already armed by the block above (otherwise no seal could
|
// 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
|
// 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 REGISTRY-BINDING: read a genesis {@code config.*} value that Besu itself does not model,
|
* AERE A8: read a genesis {@code config.*} value that Besu itself does not model, out of the
|
||||||
* out of the genesis configuration THIS NODE BOOTED WITH.
|
* genesis configuration THIS NODE BOOTED WITH.
|
||||||
*
|
*
|
||||||
* <p>Besu's {@code GenesisConfigOptions.asMap()} cannot be used for this: it is an allow-list of
|
* <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
|
* the keys Besu knows about, so a key of ours is simply absent from it and the guard would read
|
||||||
@ -705,10 +705,9 @@ public class QbftBesuControllerBuilder extends BesuControllerBuilder {
|
|||||||
private com.fasterxml.jackson.databind.JsonNode aereGenesisConfigNode(final String key) {
|
private com.fasterxml.jackson.databind.JsonNode aereGenesisConfigNode(final String key) {
|
||||||
if (!(genesisConfigOptions instanceof JsonGenesisConfigOptions)) {
|
if (!(genesisConfigOptions instanceof JsonGenesisConfigOptions)) {
|
||||||
LOG.warn(
|
LOG.warn(
|
||||||
"AERE PQC REGISTRY-BINDING: the genesis configuration is a {}, not the JSON-backed "
|
"AERE PQC A8: the genesis configuration is a {}, not the JSON-backed implementation, so "
|
||||||
+ "implementation, so config.{} cannot be read and the Falcon registry binding is "
|
+ "config.{} cannot be read and the Falcon registry binding is NOT ENFORCED on this "
|
||||||
+ "NOT ENFORCED on this node. A binding everybody believes is on and is not is worse "
|
+ "node. A binding everybody believes is on and is not is worse than no binding.",
|
||||||
+ "than no binding.",
|
|
||||||
genesisConfigOptions.getClass().getName(),
|
genesisConfigOptions.getClass().getName(),
|
||||||
key);
|
key);
|
||||||
return null;
|
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
|
* 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
|
* the License. You may obtain a copy of the License at
|
||||||
|
|||||||
@ -647,8 +647,7 @@ public class JsonGenesisConfigOptions implements GenesisConfigOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AERE REGISTRY-BINDING: the raw genesis {@code config.*} value for a key Besu does not model, or
|
* AERE A8: the raw genesis {@code config.*} value for a key Besu does not model, or null.
|
||||||
* null.
|
|
||||||
*
|
*
|
||||||
* <p>WHY THIS EXISTS. {@link #asMap()} is an allow-list of the keys Besu knows, so a key of ours
|
* <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
|
* 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
|
* 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
|
* the License. You may obtain a copy of the License at
|
||||||
|
|||||||
@ -0,0 +1,104 @@
|
|||||||
|
/* 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
@ -0,0 +1,140 @@
|
|||||||
|
/*
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,295 @@
|
|||||||
|
/*
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,286 @@
|
|||||||
|
/*
|
||||||
|
* 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
|
* 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
|
* the License. You may obtain a copy of the License at
|
||||||
@ -98,6 +98,21 @@ public final class PqAnchor {
|
|||||||
public static final Bytes COMMIT_DOMAIN_BYTES =
|
public static final Bytes COMMIT_DOMAIN_BYTES =
|
||||||
Bytes.wrap(COMMIT_DOMAIN.getBytes(StandardCharsets.US_ASCII));
|
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. */
|
/** Orders Falcon seals by their registry index, ascending. */
|
||||||
public static final Comparator<FalconSeal> BY_INDEX =
|
public static final Comparator<FalconSeal> BY_INDEX =
|
||||||
Comparator.comparingInt(FalconSeal::getValidatorIndex);
|
Comparator.comparingInt(FalconSeal::getValidatorIndex);
|
||||||
@ -216,6 +231,48 @@ public final class PqAnchor {
|
|||||||
return Hash.keccak256(out.encoded());
|
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.
|
* 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
|
* 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
|
* the License. You may obtain a copy of the License at
|
||||||
@ -103,19 +103,18 @@ import org.slf4j.LoggerFactory;
|
|||||||
* here happens before the node has joined the quorum, is visible in {@code systemctl status} in
|
* 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
|
* 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
|
* 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 operating rule that goes with this
|
* not degradation, it is a dead chain. The net for it is the one already written in the runbook -
|
||||||
* configuration - restart one at a time, never in parallel - and a preflight that computes its
|
* restart one at a time, never in parallel - and a preflight that computes its verdict from THIS
|
||||||
* verdict from THIS code path rather than from a second reading of the same strings.
|
* 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 an unbound
|
* <p><b>HONEST LIMITATION, stated in code because it is the same defect class as A8.</b> The values
|
||||||
* registry.</b> The values here are read from LOCAL system properties or environment variables,
|
* here are read from LOCAL system properties or environment variables, exactly like {@code
|
||||||
* exactly like {@code aere.falcon.registry} is today. They are NOT yet read from the genesis {@code
|
* aere.falcon.registry} is today. They are NOT yet read from the genesis {@code config.qbft} /
|
||||||
* config.qbft} / {@code config.transitions.qbft}, and there is NO consensus binding on them: two
|
* {@code config.transitions.qbft}, and there is NO consensus binding on them: two nodes configured
|
||||||
* nodes configured with different H or different K schedules will disagree about which headers are
|
* with different H or different K schedules will disagree about which headers are valid. Wiring
|
||||||
* valid. Wiring these to genesis, and refusing to start when the Falcon registry does not match the
|
* these to genesis, and refusing to start when the Falcon registry does not match the genesis {@code
|
||||||
* genesis {@code pqRegistryHash}, is a PRECONDITION of arming and is tracked as the fork-activation
|
* pqRegistryHash}, is a PRECONDITION of arming and is tracked as the fork-activation and registry
|
||||||
* and registry work items. Until that lands, a non-default value here is a laboratory setting, not
|
* work items. Until that lands, a non-default value here is a laboratory setting, not a deployment.
|
||||||
* a deployment.
|
|
||||||
*/
|
*/
|
||||||
public final class PqAnchorConfig {
|
public final class PqAnchorConfig {
|
||||||
|
|
||||||
@ -155,10 +154,9 @@ public final class PqAnchorConfig {
|
|||||||
* <p>MEASURED 2026-08-07, and this is why the property exists. A Falcon-512 seal is 666 bytes. The
|
* <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
|
* 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}
|
* with 5, 5 with 6. The rehearsal's median header of 3838 bytes is {@code (3838-525)/666 = 4.97}
|
||||||
* seals. Header bytes therefore scale with the seals actually attached: five seals is about 1.7
|
* seals. Per node per year, at ~165248 blocks/day: one seal 40.2 GB, three 120.5 GB, five 200.9
|
||||||
* times what the same chain writes capped at K=3, and about seven times a seal-less header. The
|
* GB, seven 281.2 GB. The figure carried in our own documents until that day, 45.2 GB/year, is
|
||||||
* figure used before that day had been computed for a SINGLE seal, so it understated the cost by
|
* 1.13 seals: it had been computed for a single seal and was wrong by 4.4x.
|
||||||
* about 4.4x.
|
|
||||||
*
|
*
|
||||||
* <p>Setting this to K therefore removes ~40% of the anchor's disk cost and takes nothing from the
|
* <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
|
* quorum margin, because the margin is decided by the THRESHOLD a verifier requires, not by how
|
||||||
@ -190,11 +188,10 @@ public final class PqAnchorConfig {
|
|||||||
*
|
*
|
||||||
* and it is a knob, not an accident.
|
* and it is a knob, not an accident.
|
||||||
*
|
*
|
||||||
* <p>MEASURED 2026-08-07, at K=3 capped, 666 bytes a seal: the certificate cost falls in exact
|
* <p>MEASURED 2026-08-07, at K=3 capped, ~165248 blocks/day, 666 bytes a seal, per node per year:
|
||||||
* proportion to the interval, so every 10th block costs a tenth of the every-block figure, every
|
* every block 120.5 GB; every 10th 12.1 GB; every 100th 1.2 GB; every 256th 0.5 GB. Against a
|
||||||
* 100th a hundredth, every 256th about a 250th. Against a ~523 ms block, an interval of 100 buys
|
* ~523 ms block, an interval of 100 buys a hundredfold saving for a rewritable tail that grows
|
||||||
* that hundredfold saving for a rewritable tail that grows from about half a second to about
|
* from about half a second to about fifty-two seconds. Algorand ships the same shape at 1 in 256.
|
||||||
* 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
|
* <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.
|
* cap, a weakening never arrives as a default; it has to be asked for.
|
||||||
@ -225,14 +222,14 @@ public final class PqAnchorConfig {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The stable, greppable code carried by every startup refusal raised while reading this
|
* The stable, greppable code carried by every startup refusal raised while reading this
|
||||||
* configuration, in the shape of the registry-binding refusal {@code AERE-PQC-REG-MISMATCH-01}.
|
* configuration, in the shape of the A8 registry refusal {@code AERE-PQC-REG-MISMATCH-01}.
|
||||||
*/
|
*/
|
||||||
public static final String REFUSAL_CODE = "AERE-PQC-ANCHOR-CONF-01";
|
public static final String REFUSAL_CODE = "AERE-PQC-ANCHOR-CONF-01";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MIN-SEALS FLOOR: the greppable name of the guard that refuses an armed anchor whose schedule
|
* D-147: the greppable name of the guard that refuses an armed anchor whose schedule never demands
|
||||||
* never demands a single signature. Named, and not just a message, so that a check can ask whether
|
* a single signature. Named, and not just a message, so that a check can ask whether the guard
|
||||||
* the guard EXISTS rather than whether some prose happens to be present.
|
* EXISTS rather than whether some prose happens to be present.
|
||||||
*/
|
*/
|
||||||
public static final String REFUSAL_MIN_SEALS_FLOOR = REFUSAL_CODE + "/minSealsFloor";
|
public static final String REFUSAL_MIN_SEALS_FLOOR = REFUSAL_CODE + "/minSealsFloor";
|
||||||
|
|
||||||
@ -561,12 +558,12 @@ public final class PqAnchorConfig {
|
|||||||
"AERE PQ ANCHOR: certificate carried every {} block(s) from H={}, not every block. The "
|
"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 "
|
+ "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 "
|
+ "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 header "
|
+ "anchor: fork depth <= {} blocks. This is a DELIBERATE weakening bought for disk: "
|
||||||
+ "size: the certificate cost falls to roughly 1/{} of the every-block figure.",
|
+ "at K=3 capped it is about {} GB per node per year instead of about 120.",
|
||||||
iv,
|
iv,
|
||||||
config.anchorBlock,
|
config.anchorBlock,
|
||||||
iv,
|
iv,
|
||||||
iv);
|
String.format("%.1f", 120.5 / iv));
|
||||||
}
|
}
|
||||||
if (config.maxSealsCarried().isPresent() && config.everActive()) {
|
if (config.maxSealsCarried().isPresent() && config.everActive()) {
|
||||||
LOG.warn(
|
LOG.warn(
|
||||||
@ -575,7 +572,7 @@ public final class PqAnchorConfig {
|
|||||||
+ "header cost and takes NOTHING from the quorum margin, which is decided by the "
|
+ "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 "
|
+ "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, "
|
+ "it. Measured 2026-08-07: uncapped, a K=3 chain at N=7 carries about five seals, "
|
||||||
+ "which is about 1.7 times the header bytes it writes capped at K.",
|
+ "which is 200.9 GB per node per year; capped at K it is 120.5 GB.",
|
||||||
config.maxSealsCarried().getAsInt(),
|
config.maxSealsCarried().getAsInt(),
|
||||||
config.highestEffectiveMinSeals());
|
config.highestEffectiveMinSeals());
|
||||||
}
|
}
|
||||||
@ -731,8 +728,8 @@ public final class PqAnchorConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* THE MIN-SEALS FLOOR. A schedule whose effective K is ZERO at every height leaves the anchor
|
* D-147, THE MIN-SEALS FLOOR. A schedule whose effective K is ZERO at every height leaves the
|
||||||
* ARMED as a structure and completely toothless, for ever.
|
* 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
|
* <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
|
* MISSING schedule, in its own words: {@code "the threshold schedule is missing or empty, so K
|
||||||
@ -740,12 +737,12 @@ public final class PqAnchorConfig {
|
|||||||
* exactly what the danger was. But the guard only fired on an ABSENT schedule. A schedule that is
|
* 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.
|
* 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 a staged activation recommends, one that
|
* <p>WHY THIS IS NOT THEORETICAL: it is the very shape our activation plan 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,
|
* 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
|
* 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
|
* 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 a tool that compares the
|
* the ask is valid, and the threshold stays zero for ever. Not even ancora-prag-efectiv.sh
|
||||||
* effective threshold against the requested one catches this: here both of them are zero.
|
* catches this: it compares what came out against what was asked for, and here both are zero.
|
||||||
*
|
*
|
||||||
* <p>The warm-up window stays perfectly legal: this looks at the HIGHEST K in the whole schedule,
|
* <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
|
* after the emergency ceiling, so {@code H:0,H+165000:3} passes and {@code H:0} on its own does
|
||||||
@ -1028,8 +1025,9 @@ public final class PqAnchorConfig {
|
|||||||
.append(expected)
|
.append(expected)
|
||||||
.append('\n')
|
.append('\n')
|
||||||
.append(" FIX correct BESU_OPTS on THIS node and restart ONLY this node\n")
|
.append(" FIX correct BESU_OPTS on THIS node and restart ONLY this node\n")
|
||||||
.append(" WARNING if the same value is on all seven: restart one at a time,\n")
|
.append(" WARNING if the same value is on every node: restart one at a time,\n")
|
||||||
.append(" never in parallel. At quorum 5 of 7 you lose the chain.\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(" EMERGENCY ")
|
.append(" EMERGENCY ")
|
||||||
.append(PROPERTY_DISABLE)
|
.append(PROPERTY_DISABLE)
|
||||||
.append("=true starts the node with the anchor off and shouts at every block");
|
.append("=true starts the node with the anchor off and shouts at every block");
|
||||||
@ -1232,7 +1230,7 @@ public final class PqAnchorConfig {
|
|||||||
* and forces A to be reproduced, which needs a Falcon quorum. What an adversary holding every
|
* 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
|
* 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
|
* {@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, at a hundredth of the header cost.
|
* rewritable tail, against about half a second at interval 1, and it costs a hundredth of the disk.
|
||||||
*
|
*
|
||||||
* @param interval the interval, or empty for every block
|
* @param interval the interval, or empty for every block
|
||||||
* @return a copy of this configuration carrying the interval
|
* @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
|
* 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
|
* 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
|
* <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
|
* 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
|
* 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 a measured
|
* most one proposer turn. The other cause, f validators withholding Falcon seals, is the measured
|
||||||
* exposure of its own and is the reason the validator set must grow to N>=9 before K is raised to
|
* A10 exposure 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.
|
* 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
|
* <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
|
* 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
|
* the License. You may obtain a copy of the License at
|
||||||
@ -51,8 +51,8 @@ import org.slf4j.LoggerFactory;
|
|||||||
* remove.
|
* remove.
|
||||||
*
|
*
|
||||||
* <p><b>Inert when the anchor is not configured.</b> With no {@code aere.pq.anchorBlock} this method
|
* <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 it did
|
* returns before it looks at the sync mode, so a binary carrying it behaves exactly as today on
|
||||||
* before on any chain where the anchor is not configured on any node.
|
* chain 2800 as it stands, where the anchor is not configured on any node.
|
||||||
*/
|
*/
|
||||||
public final class PqAnchorSyncModeGuard {
|
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
|
* 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
|
* the License. You may obtain a copy of the License at
|
||||||
@ -39,7 +39,7 @@ import org.slf4j.LoggerFactory;
|
|||||||
* <ul>
|
* <ul>
|
||||||
* <li>{@code quorum(N)} is Besu's own {@link BftHelpers#calculateRequiredValidatorQuorum(int)},
|
* <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
|
* {@code ceil(2N/3)}. At N=7 that is {@code ceil(14/3) = 5}, so the highest configurable
|
||||||
* threshold at that set size is <b>4</b>.
|
* threshold on chain 2800 as it stands is <b>4</b>.
|
||||||
* <li>{@code K = quorum} is a GUARANTEED HALT, and that is measured, not argued. A proposer
|
* <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
|
* 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}
|
* is IMPORTED at the quorum-th Commit; after the import {@code QbftController.consumeMessage}
|
||||||
@ -59,9 +59,9 @@ import org.slf4j.LoggerFactory;
|
|||||||
* </ul>
|
* </ul>
|
||||||
*
|
*
|
||||||
* <p><b>What is a refusal and what is only a shout.</b> Above {@code quorum - f} the schedule is
|
* <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 a measured
|
* still reachable but has no margin against f silent or keyless signers, which is the measured A10
|
||||||
* exposure of its own. That is a deliberate operator choice with a real cost, so it gets a loud
|
* and A13 exposure. That is a deliberate operator choice with a real cost, so it gets a loud WARN and
|
||||||
* WARN and the node starts. At or above {@code quorum} the schedule is not reachable at all, so it gets a
|
* 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
|
* 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.
|
* 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".
|
* 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
|
* <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
|
* anchor emergency-disarmed, this method returns before it computes anything, so a binary carrying it
|
||||||
* it behaves exactly as it did before on any chain where the anchor is armed on no node.
|
* behaves exactly as today on chain 2800, where the anchor is armed on no node.
|
||||||
*/
|
*/
|
||||||
public final class PqAnchorThresholdGuard {
|
public final class PqAnchorThresholdGuard {
|
||||||
|
|
||||||
@ -91,13 +91,26 @@ public final class PqAnchorThresholdGuard {
|
|||||||
/**
|
/**
|
||||||
* The highest seal threshold that may be configured for a validator set of this size.
|
* The highest seal threshold that may be configured for a validator set of this size.
|
||||||
*
|
*
|
||||||
* <p>One below the QBFT quorum. At N=7 this is 4.
|
* <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.
|
||||||
*
|
*
|
||||||
* @param validatorCount the number of validators, at least 1
|
* @param validatorCount the number of validators, at least 1
|
||||||
* @return the highest configurable threshold K
|
* @return the highest configurable threshold K, {@code N - f}
|
||||||
*/
|
*/
|
||||||
public static int maxConfigurableThreshold(final int validatorCount) {
|
public static int maxConfigurableThreshold(final int validatorCount) {
|
||||||
return BftHelpers.calculateRequiredValidatorQuorum(validatorCount) - 1;
|
return validatorCount - byzantineBudget(validatorCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -150,7 +163,7 @@ public final class PqAnchorThresholdGuard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final int quorum = BftHelpers.calculateRequiredValidatorQuorum(validatorCount);
|
final int quorum = BftHelpers.calculateRequiredValidatorQuorum(validatorCount);
|
||||||
final int maxConfigurable = quorum - 1;
|
final int maxConfigurable = maxConfigurableThreshold(validatorCount);
|
||||||
final int f = byzantineBudget(validatorCount);
|
final int f = byzantineBudget(validatorCount);
|
||||||
final int noMarginAbove = quorum - f;
|
final int noMarginAbove = quorum - f;
|
||||||
|
|
||||||
@ -169,7 +182,7 @@ public final class PqAnchorThresholdGuard {
|
|||||||
highest = effective;
|
highest = effective;
|
||||||
highestAt = at;
|
highestAt = at;
|
||||||
}
|
}
|
||||||
if (effective >= quorum && fatalHeight < 0L) {
|
if (effective > validatorCount - f && fatalHeight < 0L) {
|
||||||
fatalHeight = at;
|
fatalHeight = at;
|
||||||
fatalThreshold = effective;
|
fatalThreshold = effective;
|
||||||
}
|
}
|
||||||
@ -187,21 +200,18 @@ public final class PqAnchorThresholdGuard {
|
|||||||
+ validatorCount
|
+ validatorCount
|
||||||
+ " validators this node is starting into is "
|
+ " validators this node is starting into is "
|
||||||
+ quorum
|
+ quorum
|
||||||
+ " (ceil(2N/3)). The highest threshold that may be configured at this set size is "
|
+ " (ceil(2N/3)) with f = "
|
||||||
|
+ f
|
||||||
|
+ ". The highest threshold that may be configured at this set size is "
|
||||||
+ maxConfigurable
|
+ maxConfigurable
|
||||||
+ ". WHAT THIS MEANS: a proposer builds its certificate out of the Falcon seals it "
|
+ " = N - f. WHAT THIS MEANS (doctrine revised 2026-08-20 for D-227 late-seal "
|
||||||
+ "heard on Commit messages, and the block is imported at the quorum-th Commit; every "
|
+ "salvage): the seal cache accumulates seals from every ALIVE keyed validator, "
|
||||||
+ "Commit arriving after that import is discarded as targeting a height not above the "
|
+ "measured on mainnet 2800 as 8-9 seals per certificate at N=9 across 5,400 anchors. "
|
||||||
+ "chain head, so a proposer can gather at most quorum seals at ANY validator-set "
|
+ "But with f validators Byzantine or down - the design's own fault budget - at most "
|
||||||
+ "size. Measured on an isolated N=4 network with quorum 3: k=3 on every header above "
|
+ "N - f seals can ever exist, so a threshold of "
|
||||||
+ "the activation height, never 4, with all four nodes keyed and healthy. A threshold "
|
|
||||||
+ "of "
|
|
||||||
+ fatalThreshold
|
+ fatalThreshold
|
||||||
+ " therefore requires that ALL of the first "
|
+ " makes anchor blocks unreachable inside the tolerated fault model. That is a halt "
|
||||||
+ quorum
|
+ "bought by configuration, and it starts at height "
|
||||||
+ " 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
|
+ fatalHeight
|
||||||
+ ". WHAT TO DO: lower the step to at most "
|
+ ". WHAT TO DO: lower the step to at most "
|
||||||
+ maxConfigurable
|
+ maxConfigurable
|
||||||
@ -219,6 +229,26 @@ 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) {
|
if (highest > noMarginAbove) {
|
||||||
LOG.warn(
|
LOG.warn(
|
||||||
"AERE PQ ANCHOR: threshold guard PASSED but the schedule has NO MARGIN. K reaches {} at "
|
"AERE PQ ANCHOR: threshold guard PASSED but the schedule has NO MARGIN. K reaches {} at "
|
||||||
|
|||||||
@ -0,0 +1,185 @@
|
|||||||
|
/*
|
||||||
|
* 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,15 +34,14 @@ import org.slf4j.Logger;
|
|||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AERE REGISTRY BINDING: bind each registry row's FALCON PUBLIC KEY to the VALIDATOR ADDRESS it
|
* AERE D-146: bind each registry row's FALCON PUBLIC KEY to the VALIDATOR ADDRESS it sits next to.
|
||||||
* sits next to.
|
|
||||||
*
|
*
|
||||||
* <h2>The defect, measured on the real code path on 2026-08-06</h2>
|
* <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
|
* <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
|
* 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
|
* javadoc: "It says nothing about whether any validator actually holds the private key matching its
|
||||||
* registered public key." That sentence is the whole of the defect this class closes.
|
* registered public key." That sentence is the whole of D-146.
|
||||||
*
|
*
|
||||||
* <p>Concretely, the registry is a table from index i to the pair (ECDSA validator address,
|
* <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
|
* Falcon public key). Seal verification uses the KEY at index i; signer eligibility is checked
|
||||||
@ -76,7 +75,7 @@ import org.slf4j.LoggerFactory;
|
|||||||
* truncated key and that somebody holds the matching secret. It does NOT close T3, T4 or T6:
|
* 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
|
* 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
|
* possession proof for key 0 sitting under validator 1's address. Anyone who claims a Falcon
|
||||||
* proof-of-possession repairs this binding defect is wrong, and the probe measures it.
|
* proof-of-possession repairs D-146 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
|
* <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
|
* 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
|
* moved under another validator's address, indices cannot be swapped, and a key cannot appear
|
||||||
@ -145,7 +144,7 @@ import org.slf4j.LoggerFactory;
|
|||||||
* <h2>What this does NOT defend against, stated plainly</h2>
|
* <h2>What this does NOT defend against, stated plainly</h2>
|
||||||
*
|
*
|
||||||
* <ul>
|
* <ul>
|
||||||
* <li>NOT the holder of the vault. All seven validator ECDSA keys live in one place; whoever has
|
* <li>NOT the holder of the vault. Every validator ECDSA key lives in one place; whoever has
|
||||||
* them signs a perfectly valid claim for any Falcon key they like. This moves the attack from
|
* 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
|
* "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.
|
* independent people must agree to stop this chain" is unchanged, and is one.
|
||||||
@ -300,7 +299,7 @@ public final class PqRegistryBinding {
|
|||||||
verifier.init(false, pub);
|
verifier.init(false, pub);
|
||||||
return verifier.verifySignature(digest.toArray(), signature);
|
return verifier.verifySignature(digest.toArray(), signature);
|
||||||
} catch (final RuntimeException e) {
|
} catch (final RuntimeException e) {
|
||||||
LOG.debug("AERE PQC REGISTRY-BINDING: Falcon possession verify threw: {}", e.toString());
|
LOG.debug("AERE PQC D-146: Falcon possession verify threw: {}", e.toString());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -321,7 +320,7 @@ public final class PqRegistryBinding {
|
|||||||
SignatureAlgorithmFactory.getInstance().decodeSignature(Bytes.wrap(signature));
|
SignatureAlgorithmFactory.getInstance().decodeSignature(Bytes.wrap(signature));
|
||||||
return Util.signatureToAddress(s, Hash.wrap(digest));
|
return Util.signatureToAddress(s, Hash.wrap(digest));
|
||||||
} catch (final RuntimeException e) {
|
} catch (final RuntimeException e) {
|
||||||
LOG.debug("AERE PQC REGISTRY-BINDING: claim recovery threw: {}", e.toString());
|
LOG.debug("AERE PQC D-146: claim recovery threw: {}", e.toString());
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -351,7 +350,7 @@ public final class PqRegistryBinding {
|
|||||||
if (!registry.addressBound()) {
|
if (!registry.addressBound()) {
|
||||||
throw new PqRegistryHash.RegistryConfigException(
|
throw new PqRegistryHash.RegistryConfigException(
|
||||||
"AERE-PQC-REG-BIND-06",
|
"AERE-PQC-REG-BIND-06",
|
||||||
"AERE PQC REGISTRY-BINDING: registry '"
|
"AERE PQC D-146: registry '"
|
||||||
+ source
|
+ source
|
||||||
+ "' carries binding proofs but is NOT address-bound. A proof binds a Falcon key to a "
|
+ "' 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 "
|
+ "validator ADDRESS; with no addresses there is nothing to bind to. Refusing "
|
||||||
@ -371,7 +370,7 @@ public final class PqRegistryBinding {
|
|||||||
if (pop == null || pop.length == 0) {
|
if (pop == null || pop.length == 0) {
|
||||||
throw new PqRegistryHash.RegistryConfigException(
|
throw new PqRegistryHash.RegistryConfigException(
|
||||||
"AERE-PQC-REG-BIND-01",
|
"AERE-PQC-REG-BIND-01",
|
||||||
"AERE PQC REGISTRY-BINDING: registry '"
|
"AERE PQC D-146: registry '"
|
||||||
+ source
|
+ source
|
||||||
+ "' index "
|
+ "' index "
|
||||||
+ e.index()
|
+ e.index()
|
||||||
@ -382,7 +381,7 @@ public final class PqRegistryBinding {
|
|||||||
if (claim == null || claim.length == 0) {
|
if (claim == null || claim.length == 0) {
|
||||||
throw new PqRegistryHash.RegistryConfigException(
|
throw new PqRegistryHash.RegistryConfigException(
|
||||||
"AERE-PQC-REG-BIND-02",
|
"AERE-PQC-REG-BIND-02",
|
||||||
"AERE PQC REGISTRY-BINDING: registry '"
|
"AERE PQC D-146: registry '"
|
||||||
+ source
|
+ source
|
||||||
+ "' index "
|
+ "' index "
|
||||||
+ e.index()
|
+ e.index()
|
||||||
@ -395,7 +394,7 @@ public final class PqRegistryBinding {
|
|||||||
if (!verifyPossession(pk, popDigest, pop)) {
|
if (!verifyPossession(pk, popDigest, pop)) {
|
||||||
throw new PqRegistryHash.RegistryConfigException(
|
throw new PqRegistryHash.RegistryConfigException(
|
||||||
"AERE-PQC-REG-BIND-03",
|
"AERE-PQC-REG-BIND-03",
|
||||||
"AERE PQC REGISTRY-BINDING: registry '"
|
"AERE PQC D-146: registry '"
|
||||||
+ source
|
+ source
|
||||||
+ "' index "
|
+ "' index "
|
||||||
+ e.index()
|
+ e.index()
|
||||||
@ -417,7 +416,7 @@ public final class PqRegistryBinding {
|
|||||||
if (recovered == null) {
|
if (recovered == null) {
|
||||||
throw new PqRegistryHash.RegistryConfigException(
|
throw new PqRegistryHash.RegistryConfigException(
|
||||||
"AERE-PQC-REG-BIND-05",
|
"AERE-PQC-REG-BIND-05",
|
||||||
"AERE PQC REGISTRY-BINDING: registry '"
|
"AERE PQC D-146: registry '"
|
||||||
+ source
|
+ source
|
||||||
+ "' index "
|
+ "' index "
|
||||||
+ e.index()
|
+ e.index()
|
||||||
@ -431,7 +430,7 @@ public final class PqRegistryBinding {
|
|||||||
if (!recovered.equals(bound)) {
|
if (!recovered.equals(bound)) {
|
||||||
throw new PqRegistryHash.RegistryConfigException(
|
throw new PqRegistryHash.RegistryConfigException(
|
||||||
"AERE-PQC-REG-BIND-04",
|
"AERE-PQC-REG-BIND-04",
|
||||||
"AERE PQC REGISTRY-BINDING: registry '"
|
"AERE PQC D-146: registry '"
|
||||||
+ source
|
+ source
|
||||||
+ "' index "
|
+ "' index "
|
||||||
+ e.index()
|
+ e.index()
|
||||||
@ -439,7 +438,7 @@ public final class PqRegistryBinding {
|
|||||||
+ bound
|
+ bound
|
||||||
+ " but its claim was signed by "
|
+ " but its claim was signed by "
|
||||||
+ recovered
|
+ recovered
|
||||||
+ ". THIS IS THE DEFECT THIS CHECK EXISTS FOR: the Falcon key on this row was filed "
|
+ ". THIS IS THE DEFECT D-146 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 "
|
+ "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 "
|
+ "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 "
|
+ "indices, a single key holder alone satisfies the quorum threshold. Refusing "
|
||||||
@ -450,8 +449,8 @@ public final class PqRegistryBinding {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
LOG.info(
|
LOG.info(
|
||||||
"AERE PQC REGISTRY-BINDING: registry '{}' - all {} rows carry a verified Falcon possession "
|
"AERE PQC D-146: registry '{}' - all {} rows carry a verified Falcon possession proof and a "
|
||||||
+ "proof and a verified validator claim (chainId={}, bindHeight={}).",
|
+ "verified validator claim (chainId={}, bindHeight={}).",
|
||||||
source,
|
source,
|
||||||
count,
|
count,
|
||||||
chainId,
|
chainId,
|
||||||
|
|||||||
@ -34,7 +34,7 @@ import org.slf4j.Logger;
|
|||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AERE GENESIS BINDING: bind the Falcon validator-index-to-public-key REGISTRY to consensus.
|
* AERE A8: 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
|
* <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
|
* 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";
|
public static final String DOMAIN_V1 = "AERE-PQ-REGISTRY-1";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Domain tag of the canonical v2 pre-image: the same registry PLUS the row binding proofs. A
|
* Domain tag of the canonical v2 pre-image: the same registry PLUS the D-146 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
|
* 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.
|
* 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 int index;
|
||||||
private final byte[] address; // 20 bytes, or null when the source is not address-bound
|
private final byte[] address; // 20 bytes, or null when the source is not address-bound
|
||||||
private final byte[] publicKey;
|
private final byte[] publicKey;
|
||||||
private final byte[] possessionProof; // row binding proof, or null in a v1 registry
|
private final byte[] possessionProof; // D-146, or null in a v1 registry
|
||||||
private final byte[] claimProof; // row binding proof, or null in a v1 registry
|
private final byte[] claimProof; // D-146, or null in a v1 registry
|
||||||
|
|
||||||
Entry(final int index, final byte[] address, final byte[] publicKey) {
|
Entry(final int index, final byte[] address, final byte[] publicKey) {
|
||||||
this(index, address, publicKey, null, null);
|
this(index, address, publicKey, null, null);
|
||||||
@ -174,7 +174,7 @@ public final class PqRegistryHash {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ROW BINDING. The Falcon signature by this row's own key over the binding pre-image, proving somebody
|
* D-146. The Falcon signature by this row's own key over the binding pre-image, proving somebody
|
||||||
* holds the matching secret.
|
* holds the matching secret.
|
||||||
*
|
*
|
||||||
* @return the possession proof, or null in a v1 registry
|
* @return the possession proof, or null in a v1 registry
|
||||||
@ -184,9 +184,8 @@ public final class PqRegistryHash {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ROW BINDING. The ECDSA signature by this row's own VALIDATOR key over the binding pre-image.
|
* D-146. The ECDSA signature by this row's own VALIDATOR key over the binding pre-image. This is
|
||||||
* This is the half a registry writer cannot forge, and therefore the half that closes both
|
* the half a registry writer cannot forge, and therefore the half that closes T3 and T6.
|
||||||
* rebinding a row to another validator's address and swapping two rows.
|
|
||||||
*
|
*
|
||||||
* @return the claim proof, or null in a v1 registry
|
* @return the claim proof, or null in a v1 registry
|
||||||
*/
|
*/
|
||||||
@ -209,9 +208,9 @@ public final class PqRegistryHash {
|
|||||||
private final String sourcePath;
|
private final String sourcePath;
|
||||||
private final List<Entry> entries; // ascending index, contiguous from 0
|
private final List<Entry> entries; // ascending index, contiguous from 0
|
||||||
private final boolean addressBound;
|
private final boolean addressBound;
|
||||||
private final boolean proofBound; // every row carries both binding proofs
|
private final boolean proofBound; // D-146: every row carries both binding proofs
|
||||||
private final long declaredChainId; // the chainId the proofs were signed over
|
private final long declaredChainId; // D-146: the chainId the proofs were signed over
|
||||||
private final long bindHeight; // the activation height the proofs were signed over
|
private final long bindHeight; // D-146: the activation height the proofs were signed over
|
||||||
|
|
||||||
Registry(
|
Registry(
|
||||||
final SourceKind kind,
|
final SourceKind kind,
|
||||||
@ -284,7 +283,7 @@ public final class PqRegistryHash {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ROW BINDING. Whether every row carries a verified Falcon possession proof and a verified validator
|
* D-146. Whether every row carries a verified Falcon possession proof and a verified validator
|
||||||
* claim. False for every registry written before 2026-08-06.
|
* claim. False for every registry written before 2026-08-06.
|
||||||
*
|
*
|
||||||
* @return true iff proof-bound
|
* @return true iff proof-bound
|
||||||
@ -294,7 +293,7 @@ public final class PqRegistryHash {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ROW BINDING. The chain id the binding proofs were signed over, as the FILE declares it. Cross-checked
|
* D-146. 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
|
* 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.
|
* chain carries proofs that verify perfectly among themselves and belong to another chain.
|
||||||
*
|
*
|
||||||
@ -305,7 +304,7 @@ public final class PqRegistryHash {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ROW BINDING. The pqRegistryHash schedule height the binding proofs were signed over, so a row
|
* D-146. The pqRegistryHash schedule height the binding proofs were signed over, so a row
|
||||||
* retired at one rotation cannot be replayed into a later registry.
|
* retired at one rotation cannot be replayed into a later registry.
|
||||||
*
|
*
|
||||||
* @return the bind height, or -1 when not proof-bound
|
* @return the bind height, or -1 when not proof-bound
|
||||||
@ -382,7 +381,7 @@ public final class PqRegistryHash {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ROW BINDING. The canonical v2 pre-image: everything v1 commits to, plus the activation height and the
|
* D-146. The canonical v2 pre-image: everything v1 commits to, plus the activation height and the
|
||||||
* two binding proofs of every row.
|
* two binding proofs of every row.
|
||||||
*
|
*
|
||||||
* <pre>
|
* <pre>
|
||||||
@ -407,7 +406,7 @@ public final class PqRegistryHash {
|
|||||||
* outside it, they would be advisory: a node could be handed the same registry with the proof
|
* 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
|
* 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
|
* anything. With them inside, stripping a proof is a different registry with a different hash and
|
||||||
* the existing genesis-binding guard refuses it. That is also why v2 has its OWN domain tag: a v1 file cannot
|
* the existing A8 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
|
* 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.
|
* exists rather than by a new rule that could be forgotten.
|
||||||
*
|
*
|
||||||
@ -436,7 +435,7 @@ public final class PqRegistryHash {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ROW BINDING. keccak256 of the canonical v2 pre-image, as 64-hex with no {@code 0x}.
|
* D-146. 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 registry the loaded registry, which must be proof-bound
|
||||||
* @param chainId the chain id this registry is bound to
|
* @param chainId the chain id this registry is bound to
|
||||||
@ -447,7 +446,7 @@ public final class PqRegistryHash {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ROW BINDING. The canonical hash OF THIS REGISTRY: v2 when it carries binding proofs, v1 when it does
|
* D-146. 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
|
* 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.
|
* compared as v2 everywhere and a legacy one keeps exactly the number it had before this change.
|
||||||
*
|
*
|
||||||
@ -460,13 +459,13 @@ public final class PqRegistryHash {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ROW BINDING. The ARMING precondition: refuse to arm the anchor over a registry whose rows are not
|
* D-146. The ARMING precondition: refuse to arm the anchor over a registry whose rows are not
|
||||||
* bound to their validator addresses by signatures.
|
* 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
|
* <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
|
* 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
|
* 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 that defect for the life of the chain.
|
* once written, so a fleet armed over unbound rows carries D-146 for the life of the chain.
|
||||||
*
|
*
|
||||||
* <p>WIRED 2026-08-06. It is called from {@code
|
* <p>WIRED 2026-08-06. It is called from {@code
|
||||||
* FalconSealSupport.requireRegistryBindingProofsOrAbort()}, in the constructor, immediately after
|
* FalconSealSupport.requireRegistryBindingProofsOrAbort()}, in the constructor, immediately after
|
||||||
@ -484,7 +483,7 @@ public final class PqRegistryHash {
|
|||||||
}
|
}
|
||||||
throw new RegistryConfigException(
|
throw new RegistryConfigException(
|
||||||
"AERE-PQC-REG-ARM-02",
|
"AERE-PQC-REG-ARM-02",
|
||||||
"AERE PQC ROW-BINDING: REFUSING TO ARM (fail-closed) over the registry "
|
"AERE PQC D-146: REFUSING TO ARM (fail-closed) over the registry "
|
||||||
+ (registry == null ? "(none)" : "'" + registry.sourcePath() + "'")
|
+ (registry == null ? "(none)" : "'" + registry.sourcePath() + "'")
|
||||||
+ ", which carries NO binding proofs. Nothing in such a registry connects a Falcon "
|
+ ", 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 "
|
+ "public key to the validator address on the same row, so whoever writes the file "
|
||||||
@ -569,7 +568,7 @@ public final class PqRegistryHash {
|
|||||||
} catch (final IOException e) {
|
} catch (final IOException e) {
|
||||||
throw new RegistryConfigException(
|
throw new RegistryConfigException(
|
||||||
"AERE-PQC-REG-LOAD-01",
|
"AERE-PQC-REG-LOAD-01",
|
||||||
"AERE PQC GENESIS-BINDING: cannot read the Falcon registry file '"
|
"AERE PQC A8: cannot read the Falcon registry file '"
|
||||||
+ path
|
+ path
|
||||||
+ "': "
|
+ "': "
|
||||||
+ e
|
+ e
|
||||||
@ -582,7 +581,7 @@ public final class PqRegistryHash {
|
|||||||
if (countRaw == null) {
|
if (countRaw == null) {
|
||||||
throw new RegistryConfigException(
|
throw new RegistryConfigException(
|
||||||
"AERE-PQC-REG-LOAD-02",
|
"AERE-PQC-REG-LOAD-02",
|
||||||
"AERE PQC GENESIS-BINDING: the Falcon registry '"
|
"AERE PQC A8: the Falcon registry '"
|
||||||
+ path
|
+ path
|
||||||
+ "' has no 'count' property. Refusing to continue (fail-closed): without a declared "
|
+ "' 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 "
|
+ "count there is no way to tell a complete registry from one that lost its last "
|
||||||
@ -591,7 +590,7 @@ public final class PqRegistryHash {
|
|||||||
}
|
}
|
||||||
final int count = parsePositiveInt(countRaw, "count", path.toString());
|
final int count = parsePositiveInt(countRaw, "count", path.toString());
|
||||||
|
|
||||||
// AERE ROW BINDING. Header fields of the v2 format. A v1 node reading a v2 file does NOT silently
|
// AERE D-146. 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
|
// 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
|
// 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.
|
// direction of failure and it is why the fields are plain top-level names.
|
||||||
@ -628,7 +627,7 @@ public final class PqRegistryHash {
|
|||||||
if (a.length != 20) {
|
if (a.length != 20) {
|
||||||
throw new RegistryConfigException(
|
throw new RegistryConfigException(
|
||||||
"AERE-PQC-REG-LOAD-03",
|
"AERE-PQC-REG-LOAD-03",
|
||||||
"AERE PQC GENESIS-BINDING: registry '"
|
"AERE PQC A8: registry '"
|
||||||
+ path
|
+ path
|
||||||
+ "' entry "
|
+ "' entry "
|
||||||
+ i
|
+ i
|
||||||
@ -679,7 +678,7 @@ public final class PqRegistryHash {
|
|||||||
if (cfg.isMissingNode()) {
|
if (cfg.isMissingNode()) {
|
||||||
throw new RegistryConfigException(
|
throw new RegistryConfigException(
|
||||||
"AERE-PQC-REG-LOAD-04",
|
"AERE-PQC-REG-LOAD-04",
|
||||||
"AERE PQC GENESIS-BINDING: genesis '"
|
"AERE PQC A8: genesis '"
|
||||||
+ genesisPath
|
+ genesisPath
|
||||||
+ "' has no config.aereFalconRegistry manifest. Refusing to continue (fail-closed).");
|
+ "' has no config.aereFalconRegistry manifest. Refusing to continue (fail-closed).");
|
||||||
}
|
}
|
||||||
@ -700,7 +699,7 @@ public final class PqRegistryHash {
|
|||||||
} catch (final IOException e) {
|
} catch (final IOException e) {
|
||||||
throw new RegistryConfigException(
|
throw new RegistryConfigException(
|
||||||
"AERE-PQC-REG-LOAD-01",
|
"AERE-PQC-REG-LOAD-01",
|
||||||
"AERE PQC GENESIS-BINDING: cannot read '" + path + "': " + e + ". Refusing to continue (fail-closed).");
|
"AERE PQC A8: cannot read '" + path + "': " + e + ". Refusing to continue (fail-closed).");
|
||||||
}
|
}
|
||||||
int i = 0;
|
int i = 0;
|
||||||
while (i < raw.length && Character.isWhitespace((char) (raw[i] & 0xff))) {
|
while (i < raw.length && Character.isWhitespace((char) (raw[i] & 0xff))) {
|
||||||
@ -721,7 +720,7 @@ public final class PqRegistryHash {
|
|||||||
if (cfg == null || cfg.isMissingNode() || !cfg.has("count")) {
|
if (cfg == null || cfg.isMissingNode() || !cfg.has("count")) {
|
||||||
throw new RegistryConfigException(
|
throw new RegistryConfigException(
|
||||||
"AERE-PQC-REG-LOAD-05",
|
"AERE-PQC-REG-LOAD-05",
|
||||||
"AERE PQC GENESIS-BINDING: manifest '" + source + "' has no 'count'. Refusing (fail-closed).");
|
"AERE PQC A8: manifest '" + source + "' has no 'count'. Refusing (fail-closed).");
|
||||||
}
|
}
|
||||||
final int count = parsePositiveInt(cfg.get("count").asText(), "count", source);
|
final int count = parsePositiveInt(cfg.get("count").asText(), "count", source);
|
||||||
|
|
||||||
@ -749,7 +748,7 @@ public final class PqRegistryHash {
|
|||||||
} catch (final NumberFormatException e) {
|
} catch (final NumberFormatException e) {
|
||||||
throw new RegistryConfigException(
|
throw new RegistryConfigException(
|
||||||
"AERE-PQC-REG-LOAD-18",
|
"AERE-PQC-REG-LOAD-18",
|
||||||
"AERE PQC GENESIS-BINDING: manifest '"
|
"AERE PQC A8: manifest '"
|
||||||
+ source
|
+ source
|
||||||
+ "' has the unrecognised field '"
|
+ "' has the unrecognised field '"
|
||||||
+ n
|
+ n
|
||||||
@ -762,7 +761,7 @@ public final class PqRegistryHash {
|
|||||||
if (!strays.isEmpty()) {
|
if (!strays.isEmpty()) {
|
||||||
throw new RegistryConfigException(
|
throw new RegistryConfigException(
|
||||||
"AERE-PQC-REG-LOAD-09",
|
"AERE-PQC-REG-LOAD-09",
|
||||||
"AERE PQC GENESIS-BINDING: manifest '"
|
"AERE PQC A8: manifest '"
|
||||||
+ source
|
+ source
|
||||||
+ "' declares count="
|
+ "' declares count="
|
||||||
+ count
|
+ count
|
||||||
@ -795,7 +794,7 @@ public final class PqRegistryHash {
|
|||||||
if (e == null) {
|
if (e == null) {
|
||||||
throw new RegistryConfigException(
|
throw new RegistryConfigException(
|
||||||
"AERE-PQC-REG-LOAD-06",
|
"AERE-PQC-REG-LOAD-06",
|
||||||
"AERE PQC GENESIS-BINDING: manifest '"
|
"AERE PQC A8: manifest '"
|
||||||
+ source
|
+ source
|
||||||
+ "' declares count="
|
+ "' declares count="
|
||||||
+ count
|
+ count
|
||||||
@ -809,7 +808,7 @@ public final class PqRegistryHash {
|
|||||||
if (a == null || !a.isTextual() || k == null || !k.isTextual()) {
|
if (a == null || !a.isTextual() || k == null || !k.isTextual()) {
|
||||||
throw new RegistryConfigException(
|
throw new RegistryConfigException(
|
||||||
"AERE-PQC-REG-LOAD-07",
|
"AERE-PQC-REG-LOAD-07",
|
||||||
"AERE PQC GENESIS-BINDING: manifest '"
|
"AERE PQC A8: manifest '"
|
||||||
+ source
|
+ source
|
||||||
+ "' entry "
|
+ "' entry "
|
||||||
+ i
|
+ i
|
||||||
@ -819,7 +818,7 @@ public final class PqRegistryHash {
|
|||||||
if (addr.length != 20) {
|
if (addr.length != 20) {
|
||||||
throw new RegistryConfigException(
|
throw new RegistryConfigException(
|
||||||
"AERE-PQC-REG-LOAD-03",
|
"AERE-PQC-REG-LOAD-03",
|
||||||
"AERE PQC GENESIS-BINDING: manifest '"
|
"AERE PQC A8: manifest '"
|
||||||
+ source
|
+ source
|
||||||
+ "' entry "
|
+ "' entry "
|
||||||
+ i
|
+ i
|
||||||
@ -842,7 +841,7 @@ public final class PqRegistryHash {
|
|||||||
} else {
|
} else {
|
||||||
throw new RegistryConfigException(
|
throw new RegistryConfigException(
|
||||||
"AERE-PQC-REG-LOAD-08",
|
"AERE-PQC-REG-LOAD-08",
|
||||||
"AERE PQC GENESIS-BINDING: manifest '"
|
"AERE PQC A8: manifest '"
|
||||||
+ source
|
+ source
|
||||||
+ "' entry "
|
+ "' entry "
|
||||||
+ i
|
+ i
|
||||||
@ -859,7 +858,7 @@ public final class PqRegistryHash {
|
|||||||
* -Werror}, so a concrete collection type in a method signature is a build FAILURE, not a style
|
* -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
|
* 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
|
* 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 this could be called closed. {@code
|
* wiring had to be compiled in the real tree before A8 could be called closed. {@code
|
||||||
* NavigableMap} keeps the guarantee the code actually relies on, which is ascending key order.
|
* NavigableMap} keeps the guarantee the code actually relies on, which is ascending key order.
|
||||||
*/
|
*/
|
||||||
private static Registry assemble(
|
private static Registry assemble(
|
||||||
@ -877,7 +876,7 @@ public final class PqRegistryHash {
|
|||||||
if (pks.size() != count) {
|
if (pks.size() != count) {
|
||||||
throw new RegistryConfigException(
|
throw new RegistryConfigException(
|
||||||
"AERE-PQC-REG-LOAD-09",
|
"AERE-PQC-REG-LOAD-09",
|
||||||
"AERE PQC GENESIS-BINDING: registry '"
|
"AERE PQC A8: registry '"
|
||||||
+ source
|
+ source
|
||||||
+ "' declares count="
|
+ "' declares count="
|
||||||
+ count
|
+ count
|
||||||
@ -894,7 +893,7 @@ public final class PqRegistryHash {
|
|||||||
if (!pks.containsKey(i)) {
|
if (!pks.containsKey(i)) {
|
||||||
throw new RegistryConfigException(
|
throw new RegistryConfigException(
|
||||||
"AERE-PQC-REG-LOAD-10",
|
"AERE-PQC-REG-LOAD-10",
|
||||||
"AERE PQC GENESIS-BINDING: registry '"
|
"AERE PQC A8: registry '"
|
||||||
+ source
|
+ source
|
||||||
+ "' is missing index "
|
+ "' is missing index "
|
||||||
+ i
|
+ i
|
||||||
@ -907,7 +906,7 @@ public final class PqRegistryHash {
|
|||||||
if (pks.get(i).length == 0) {
|
if (pks.get(i).length == 0) {
|
||||||
throw new RegistryConfigException(
|
throw new RegistryConfigException(
|
||||||
"AERE-PQC-REG-LOAD-11",
|
"AERE-PQC-REG-LOAD-11",
|
||||||
"AERE PQC GENESIS-BINDING: registry '" + source + "' index " + i + " has an EMPTY public key. "
|
"AERE PQC A8: registry '" + source + "' index " + i + " has an EMPTY public key. "
|
||||||
+ "Refusing (fail-closed).");
|
+ "Refusing (fail-closed).");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -920,7 +919,7 @@ public final class PqRegistryHash {
|
|||||||
if (!addrs.containsKey(i)) {
|
if (!addrs.containsKey(i)) {
|
||||||
throw new RegistryConfigException(
|
throw new RegistryConfigException(
|
||||||
"AERE-PQC-REG-LOAD-12",
|
"AERE-PQC-REG-LOAD-12",
|
||||||
"AERE PQC GENESIS-BINDING: registry '"
|
"AERE PQC A8: registry '"
|
||||||
+ source
|
+ source
|
||||||
+ "' binds addresses for "
|
+ "' binds addresses for "
|
||||||
+ addrs.keySet()
|
+ addrs.keySet()
|
||||||
@ -932,7 +931,7 @@ public final class PqRegistryHash {
|
|||||||
} else {
|
} else {
|
||||||
throw new RegistryConfigException(
|
throw new RegistryConfigException(
|
||||||
"AERE-PQC-REG-LOAD-12",
|
"AERE-PQC-REG-LOAD-12",
|
||||||
"AERE PQC GENESIS-BINDING: registry '"
|
"AERE PQC A8: registry '"
|
||||||
+ source
|
+ source
|
||||||
+ "' is MIXED: "
|
+ "' is MIXED: "
|
||||||
+ addrs.size()
|
+ addrs.size()
|
||||||
@ -943,7 +942,7 @@ public final class PqRegistryHash {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ===============================================================================
|
// ===============================================================================
|
||||||
// AERE ROW BINDING (2026-08-06). UNIQUENESS. Nothing here needs a signature, and it is the half that
|
// AERE D-146 (2026-08-06). UNIQUENESS. Nothing here needs a signature, and it is the half that
|
||||||
// makes the THRESHOLD real again.
|
// makes the THRESHOLD real again.
|
||||||
//
|
//
|
||||||
// MEASURED on the real verification path: a registry carrying ONE public key at TWO indices was
|
// MEASURED on the real verification path: a registry carrying ONE public key at TWO indices was
|
||||||
@ -965,7 +964,7 @@ public final class PqRegistryHash {
|
|||||||
if (first != null) {
|
if (first != null) {
|
||||||
throw new RegistryConfigException(
|
throw new RegistryConfigException(
|
||||||
"AERE-PQC-REG-LOAD-19",
|
"AERE-PQC-REG-LOAD-19",
|
||||||
"AERE PQC ROW-BINDING: registry '"
|
"AERE PQC D-146: registry '"
|
||||||
+ source
|
+ source
|
||||||
+ "' carries the SAME Falcon public key at index "
|
+ "' carries the SAME Falcon public key at index "
|
||||||
+ first
|
+ first
|
||||||
@ -987,7 +986,7 @@ public final class PqRegistryHash {
|
|||||||
if (first != null) {
|
if (first != null) {
|
||||||
throw new RegistryConfigException(
|
throw new RegistryConfigException(
|
||||||
"AERE-PQC-REG-LOAD-20",
|
"AERE-PQC-REG-LOAD-20",
|
||||||
"AERE PQC ROW-BINDING: registry '"
|
"AERE PQC D-146: registry '"
|
||||||
+ source
|
+ source
|
||||||
+ "' binds the SAME validator address 0x"
|
+ "' binds the SAME validator address 0x"
|
||||||
+ a
|
+ a
|
||||||
@ -1002,7 +1001,7 @@ public final class PqRegistryHash {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// AERE ROW BINDING. FORMAT COHERENCE. Binding proofs are all-or-nothing, exactly like address binding,
|
// AERE D-146. 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.
|
// 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 anyProof = !pops.isEmpty() || !claims.isEmpty();
|
||||||
final boolean proofBound;
|
final boolean proofBound;
|
||||||
@ -1010,7 +1009,7 @@ public final class PqRegistryHash {
|
|||||||
if (pops.size() != count || claims.size() != count) {
|
if (pops.size() != count || claims.size() != count) {
|
||||||
throw new RegistryConfigException(
|
throw new RegistryConfigException(
|
||||||
"AERE-PQC-REG-LOAD-21",
|
"AERE-PQC-REG-LOAD-21",
|
||||||
"AERE PQC ROW-BINDING: registry '"
|
"AERE PQC D-146: registry '"
|
||||||
+ source
|
+ source
|
||||||
+ "' declares count="
|
+ "' declares count="
|
||||||
+ count
|
+ count
|
||||||
@ -1025,7 +1024,7 @@ public final class PqRegistryHash {
|
|||||||
if (declaredChainId < 0 || bindHeight < 0) {
|
if (declaredChainId < 0 || bindHeight < 0) {
|
||||||
throw new RegistryConfigException(
|
throw new RegistryConfigException(
|
||||||
"AERE-PQC-REG-LOAD-22",
|
"AERE-PQC-REG-LOAD-22",
|
||||||
"AERE PQC ROW-BINDING: registry '"
|
"AERE PQC D-146: registry '"
|
||||||
+ source
|
+ source
|
||||||
+ "' carries binding proofs but does not declare both 'chainId' and 'bindHeight'. "
|
+ "' 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 "
|
+ "Both are inside the signed message, so without them the proofs cannot even be "
|
||||||
@ -1047,7 +1046,7 @@ public final class PqRegistryHash {
|
|||||||
proofBound ? claims.get(i) : null));
|
proofBound ? claims.get(i) : null));
|
||||||
if (pks.get(i).length != FALCON_512_PK_LENGTH) {
|
if (pks.get(i).length != FALCON_512_PK_LENGTH) {
|
||||||
LOG.warn(
|
LOG.warn(
|
||||||
"AERE PQC GENESIS-BINDING: registry '{}' index {} carries a {}-byte public key; Falcon-512 public "
|
"AERE PQC A8: registry '{}' index {} carries a {}-byte public key; Falcon-512 public "
|
||||||
+ "keys are {} bytes. The registry is NOT rejected for this (the canonical form is "
|
+ "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 "
|
+ "length-prefixed and handles any length), but it is almost certainly the wrong "
|
||||||
+ "file or a truncated copy.",
|
+ "file or a truncated copy.",
|
||||||
@ -1059,18 +1058,18 @@ public final class PqRegistryHash {
|
|||||||
}
|
}
|
||||||
final Registry assembled =
|
final Registry assembled =
|
||||||
new Registry(kind, source, entries, bound, proofBound, declaredChainId, bindHeight);
|
new Registry(kind, source, entries, bound, proofBound, declaredChainId, bindHeight);
|
||||||
// AERE ROW BINDING. THE VERIFICATION ITSELF, on the single path every loader funnels through, so it
|
// AERE D-146. 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)
|
// 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.
|
// per process start with zero cost per block.
|
||||||
PqRegistryBinding.verifyOrThrow(assembled);
|
PqRegistryBinding.verifyOrThrow(assembled);
|
||||||
if (!proofBound) {
|
if (!proofBound) {
|
||||||
LOG.warn(
|
LOG.warn(
|
||||||
"AERE PQC ROW-BINDING: registry '{}' ({} rows) carries NO binding proofs. Nothing in it "
|
"AERE PQC D-146: registry '{}' ({} rows) carries NO binding proofs. Nothing in it "
|
||||||
+ "connects a Falcon public key to the validator address on the same row, so whoever "
|
+ "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 "
|
+ "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 "
|
+ "real verification path: swapping two rows produces an ACCEPTED header with no "
|
||||||
+ "duplicate key and no duplicate address. This is the row-binding defect, and it "
|
+ "duplicate key and no duplicate address. This is D-146 and it is not closed on this "
|
||||||
+ "is not closed on this node.",
|
+ "node.",
|
||||||
source,
|
source,
|
||||||
count);
|
count);
|
||||||
}
|
}
|
||||||
@ -1206,7 +1205,7 @@ public final class PqRegistryHash {
|
|||||||
if (node.isEmpty()) {
|
if (node.isEmpty()) {
|
||||||
throw new RegistryConfigException(
|
throw new RegistryConfigException(
|
||||||
"AERE-PQC-REG-SCHED-01",
|
"AERE-PQC-REG-SCHED-01",
|
||||||
"AERE PQC GENESIS-BINDING: "
|
"AERE PQC A8: "
|
||||||
+ source
|
+ source
|
||||||
+ " is an EMPTY array. Refusing to start (fail-closed): an empty schedule is not "
|
+ " 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 "
|
+ "the same statement as an absent one, and guessing which was meant is exactly how "
|
||||||
@ -1218,7 +1217,7 @@ public final class PqRegistryHash {
|
|||||||
if (!e.isObject() || !e.has("block") || !e.has("hash")) {
|
if (!e.isObject() || !e.has("block") || !e.has("hash")) {
|
||||||
throw new RegistryConfigException(
|
throw new RegistryConfigException(
|
||||||
"AERE-PQC-REG-SCHED-02",
|
"AERE-PQC-REG-SCHED-02",
|
||||||
"AERE PQC GENESIS-BINDING: "
|
"AERE PQC A8: "
|
||||||
+ source
|
+ source
|
||||||
+ " entry "
|
+ " entry "
|
||||||
+ e
|
+ e
|
||||||
@ -1228,7 +1227,7 @@ public final class PqRegistryHash {
|
|||||||
if (block < 0) {
|
if (block < 0) {
|
||||||
throw new RegistryConfigException(
|
throw new RegistryConfigException(
|
||||||
"AERE-PQC-REG-SCHED-03",
|
"AERE-PQC-REG-SCHED-03",
|
||||||
"AERE PQC GENESIS-BINDING: "
|
"AERE PQC A8: "
|
||||||
+ source
|
+ source
|
||||||
+ " has a negative or unparseable block in "
|
+ " has a negative or unparseable block in "
|
||||||
+ e
|
+ e
|
||||||
@ -1237,7 +1236,7 @@ public final class PqRegistryHash {
|
|||||||
if (block <= previous) {
|
if (block <= previous) {
|
||||||
throw new RegistryConfigException(
|
throw new RegistryConfigException(
|
||||||
"AERE-PQC-REG-SCHED-04",
|
"AERE-PQC-REG-SCHED-04",
|
||||||
"AERE PQC GENESIS-BINDING: "
|
"AERE PQC A8: "
|
||||||
+ source
|
+ source
|
||||||
+ " blocks are not STRICTLY INCREASING ("
|
+ " blocks are not STRICTLY INCREASING ("
|
||||||
+ previous
|
+ previous
|
||||||
@ -1253,7 +1252,7 @@ public final class PqRegistryHash {
|
|||||||
} else {
|
} else {
|
||||||
throw new RegistryConfigException(
|
throw new RegistryConfigException(
|
||||||
"AERE-PQC-REG-SCHED-05",
|
"AERE-PQC-REG-SCHED-05",
|
||||||
"AERE PQC GENESIS-BINDING: "
|
"AERE PQC A8: "
|
||||||
+ source
|
+ source
|
||||||
+ " must be a 0x-prefixed 32-byte hash or an array of {block, hash}; found "
|
+ " must be a 0x-prefixed 32-byte hash or an array of {block, hash}; found "
|
||||||
+ node.getNodeType()
|
+ node.getNodeType()
|
||||||
@ -1320,11 +1319,11 @@ public final class PqRegistryHash {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ===================================================================================
|
// ===================================================================================
|
||||||
// HEIGHT SCHEDULE: the whole scheduled history, not only the entry in force at the head
|
// D-081: the whole scheduled history, not only the entry in force at the head
|
||||||
// ===================================================================================
|
// ===================================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HEIGHT SCHEDULE. The registries a node holds, indexed by the SCHEDULE ENTRY each one satisfies.
|
* D-081. 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
|
* <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,
|
* schedule, and a second entry is how a key rotation or a revocation is expressed. Enforcement,
|
||||||
@ -1361,7 +1360,7 @@ public final class PqRegistryHash {
|
|||||||
private final List<Long> entryBlocks; // ascending, one per COVERED schedule entry
|
private final List<Long> entryBlocks; // ascending, one per COVERED schedule entry
|
||||||
private final List<Registry> entryRegistries; // parallel to entryBlocks
|
private final List<Registry> entryRegistries; // parallel to entryBlocks
|
||||||
private final List<Long> uncovered; // schedule entry blocks with no matching registry
|
private final List<Long> uncovered; // schedule entry blocks with no matching registry
|
||||||
private final List<Misbound> misbound; // signed height did not match the scheduled one
|
private final List<Misbound> misbound; // D-B: hash matched, signed height did not
|
||||||
|
|
||||||
RegistrySet(
|
RegistrySet(
|
||||||
final List<Registry> loaded,
|
final List<Registry> loaded,
|
||||||
@ -1404,7 +1403,7 @@ public final class PqRegistryHash {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SIGNED-HEIGHT CHECK. The schedule entries a held registry reproduces BY HASH and yet was not signed for.
|
* D-B. 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
|
* @return an unmodifiable list, empty when every covered entry is coherently signed
|
||||||
*/
|
*/
|
||||||
@ -1471,7 +1470,7 @@ public final class PqRegistryHash {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HEIGHT SCHEDULE. Bind a set of loaded registries to the schedule by canonical hash.
|
* D-081. 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
|
* <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
|
* required hash it reproduces, and nothing else makes it cover anything. An operator therefore
|
||||||
@ -1500,8 +1499,8 @@ public final class PqRegistryHash {
|
|||||||
if (!hashFor(r, chainId).equalsIgnoreCase(e.hash)) {
|
if (!hashFor(r, chainId).equalsIgnoreCase(e.hash)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// AERE SIGNED-HEIGHT CHECK (2026-08-06). THE LINK THAT WAS NEVER DRAWN. Both numbers have been in this
|
// AERE D-B (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.
|
// lexical scope since D-081 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
|
// 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
|
// were SIGNED OVER (PqRegistryBinding.bindingPreimage). e.block is the height from which
|
||||||
@ -1513,9 +1512,9 @@ public final class PqRegistryHash {
|
|||||||
// The node and the tool disagreed and nothing put them face to face.
|
// 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
|
// WHAT THAT BUYS AN OPERATOR WHO IS NOT SUPPOSED TO HAVE IT: moving the activation day
|
||||||
// costs 14 fresh signatures if this is checked, and ZERO if it is not. The seven
|
// costs two fresh signatures per validator if this is checked, and ZERO if it is not.
|
||||||
// validators' agreement on a height is only an agreement if something refuses the heights
|
// The validators' agreement on a height is only an agreement if something refuses the
|
||||||
// they did not sign.
|
// heights they did not sign.
|
||||||
if (r.proofBound() && r.bindHeight() != e.block) {
|
if (r.proofBound() && r.bindHeight() != e.block) {
|
||||||
misbound.add(new Misbound(e.block, e.hash, r.sourcePath(), r.bindHeight()));
|
misbound.add(new Misbound(e.block, e.hash, r.sourcePath(), r.bindHeight()));
|
||||||
continue;
|
continue;
|
||||||
@ -1534,7 +1533,7 @@ public final class PqRegistryHash {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SIGNED-HEIGHT CHECK. One schedule entry whose required hash a held registry reproduces, and whose height that
|
* D-B. One schedule entry whose required hash a held registry reproduces, and whose height that
|
||||||
* registry's binding proofs were not signed over.
|
* registry's binding proofs were not signed over.
|
||||||
*
|
*
|
||||||
* @param entryBlock the height genesis puts the registry in force from
|
* @param entryBlock the height genesis puts the registry in force from
|
||||||
@ -1546,7 +1545,7 @@ public final class PqRegistryHash {
|
|||||||
long entryBlock, String entryHash, String registryPath, long signedHeight) {}
|
long entryBlock, String entryHash, String registryPath, long signedHeight) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HEIGHT SCHEDULE. Load every registry named and bind the result to the schedule.
|
* D-081. Load every registry named and bind the result to the schedule.
|
||||||
*
|
*
|
||||||
* @param schedule the parsed schedule
|
* @param schedule the parsed schedule
|
||||||
* @param paths the registry files this node holds
|
* @param paths the registry files this node holds
|
||||||
@ -1564,7 +1563,7 @@ public final class PqRegistryHash {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HEIGHT SCHEDULE. Split a comma-separated list of registry paths. Blank elements are dropped; a null or
|
* D-081. 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 "".
|
* blank list gives an empty result rather than a path named "".
|
||||||
*
|
*
|
||||||
* @param raw the configured value, may be null
|
* @param raw the configured value, may be null
|
||||||
@ -1585,7 +1584,7 @@ public final class PqRegistryHash {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HEIGHT SCHEDULE. The registry in force at a height: the one bound to the schedule entry active there.
|
* D-081. 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
|
* <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
|
* produced under the key set the chain required at h, so it must be checked against that key set
|
||||||
@ -1606,7 +1605,7 @@ public final class PqRegistryHash {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HEIGHT SCHEDULE. Whether the registries this node holds satisfy the binding active at a height.
|
* D-081. 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
|
* <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
|
* answer is a positive proof about the bytes the node is holding right now and not a restatement
|
||||||
@ -1712,9 +1711,9 @@ public final class PqRegistryHash {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AERE HELD-SET SCOPE (2026-08-06). THE SAME GUARD, ASKED OF EVERY REGISTRY THIS NODE HOLDS.
|
* AERE D-A (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. The height schedule gave a node a HISTORY of
|
* <p>WHY THIS OVERLOAD HAD TO EXIST, and it is not tidiness. D-081 gave a node a HISTORY of
|
||||||
* registry files, one per rotation the chain has ever performed, precisely so that a node can
|
* 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
|
* 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
|
* the schedule against ONE registry, the one named by {@code registrySourcePath}, and the history
|
||||||
@ -1741,19 +1740,19 @@ public final class PqRegistryHash {
|
|||||||
final long chainHeadNumber,
|
final long chainHeadNumber,
|
||||||
final long chainId) {
|
final long chainId) {
|
||||||
|
|
||||||
// AERE ROW BINDING. The proofs are signed over a chainId the FILE declares. A registry lifted from
|
// AERE D-146. 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
|
// 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,
|
// 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.
|
// where the node's real chain id is known and the file's is not yet trusted.
|
||||||
//
|
//
|
||||||
// AERE HELD-SET SCOPE (2026-08-06): over EVERY held file, not only the primary. A history file lifted from
|
// AERE D-A (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
|
// 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.
|
// an interval of history - and before this it was never asked.
|
||||||
for (final Registry r : set.loaded()) {
|
for (final Registry r : set.loaded()) {
|
||||||
if (r.proofBound() && r.declaredChainId() != chainId) {
|
if (r.proofBound() && r.declaredChainId() != chainId) {
|
||||||
throw new RegistryConfigException(
|
throw new RegistryConfigException(
|
||||||
"AERE-PQC-REG-BIND-07",
|
"AERE-PQC-REG-BIND-07",
|
||||||
"AERE PQC ROW-BINDING: registry '"
|
"AERE PQC D-146: registry '"
|
||||||
+ r.sourcePath()
|
+ r.sourcePath()
|
||||||
+ "' declares chainId="
|
+ "' declares chainId="
|
||||||
+ r.declaredChainId()
|
+ r.declaredChainId()
|
||||||
@ -1768,7 +1767,7 @@ public final class PqRegistryHash {
|
|||||||
|
|
||||||
if (!schedule.enforced()) {
|
if (!schedule.enforced()) {
|
||||||
LOG.warn(
|
LOG.warn(
|
||||||
"AERE PQC GENESIS-BINDING: pqRegistryHash is NOT CONFIGURED ({}), so the Falcon registry is NOT bound "
|
"AERE PQC A8: pqRegistryHash is NOT CONFIGURED ({}), so the Falcon registry is NOT bound "
|
||||||
+ "to consensus on this node. The registry currently loaded is {} ({}, {} entries, "
|
+ "to consensus on this node. The registry currently loaded is {} ({}, {} entries, "
|
||||||
+ "address-bound={}), canonical hash 0x{}. Two nodes holding DIFFERENT registry files "
|
+ "address-bound={}), canonical hash 0x{}. Two nodes holding DIFFERENT registry files "
|
||||||
+ "will disagree about which public key validator index i has, so the same "
|
+ "will disagree about which public key validator index i has, so the same "
|
||||||
@ -1779,18 +1778,18 @@ public final class PqRegistryHash {
|
|||||||
registry == null ? SourceKind.NONE : registry.kind(),
|
registry == null ? SourceKind.NONE : registry.kind(),
|
||||||
registry == null ? 0 : registry.count(),
|
registry == null ? 0 : registry.count(),
|
||||||
registry != null && registry.addressBound(),
|
registry != null && registry.addressBound(),
|
||||||
// GENESIS BINDING, measured on a fleet of seven on 2026-08-06: this printed hashV1 next
|
// A8, measured on a fleet of seven on 2026-08-06: this printed hashV1 next to the text
|
||||||
// to the text "put this in config.pqRegistryHash", while THE GATE compares hashFor,
|
// "put this in config.pqRegistryHash", while THE GATE compares hashFor, which for a
|
||||||
// which for a registry carrying proofs is hashV2. With the printed value put into
|
// registry carrying proofs is hashV2. With the printed value put into genesis, all seven
|
||||||
// genesis, all seven nodes start, all seven report the registry loaded, and THE CHAIN
|
// nodes start, all seven report the registry loaded, and THE CHAIN STOPS AT H-1. The
|
||||||
// STOPS AT H-1. The guard shouts NOT CORRECTLY STAGED, so it is not a silent halt, but
|
// guard shouts NOT CORRECTLY STAGED, so it is not a silent halt, but the operator who
|
||||||
// the operator who follows the node's own instruction halts the fleet. A wrong
|
// follows the node's own instruction halts the fleet. A wrong instruction is more
|
||||||
// instruction is more dangerous than no instruction at all.
|
// dangerous than no instruction at all.
|
||||||
registry == null ? "(no registry)" : hashFor(registry, chainId));
|
registry == null ? "(no registry)" : hashFor(registry, chainId));
|
||||||
return GateState.NOT_ENFORCED_NO_SCHEDULE;
|
return GateState.NOT_ENFORCED_NO_SCHEDULE;
|
||||||
}
|
}
|
||||||
|
|
||||||
// AERE SIGNED-HEIGHT CHECK (2026-08-06). THE SILENT DEFERRAL. Placed HERE, below the not-enforced exit above,
|
// AERE D-B (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
|
// 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
|
// exist, schedule.enforced() is false, and the return above is the first executable statement
|
||||||
// this guard reaches. Nothing new is ever put above it.
|
// this guard reaches. Nothing new is ever put above it.
|
||||||
@ -1815,7 +1814,7 @@ public final class PqRegistryHash {
|
|||||||
}
|
}
|
||||||
throw new RegistryConfigException(
|
throw new RegistryConfigException(
|
||||||
"AERE-PQC-REG-BIND-08",
|
"AERE-PQC-REG-BIND-08",
|
||||||
"AERE PQC SIGNED-HEIGHT: REFUSING TO START - a registry this node holds is scheduled at a height "
|
"AERE PQC D-B: REFUSING TO START - a registry this node holds is scheduled at a height "
|
||||||
+ "its validators never signed for.\n"
|
+ "its validators never signed for.\n"
|
||||||
+ " FIELD: 'bindHeight', inside the registry file, versus 'block' of the matching "
|
+ " FIELD: 'bindHeight', inside the registry file, versus 'block' of the matching "
|
||||||
+ "entry of config.pqRegistryHash in genesis ("
|
+ "entry of config.pqRegistryHash in genesis ("
|
||||||
@ -1857,8 +1856,8 @@ public final class PqRegistryHash {
|
|||||||
// - the operator puts the OLD registry back: the node starts and still cannot pass X.
|
// - the operator puts the OLD registry back: the node starts and still cannot pass X.
|
||||||
// Measured.
|
// Measured.
|
||||||
// Old registry: starts, cannot advance. New registry: cannot start. There was no third file,
|
// 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
|
// and the only exit measured was to set the emergency bypass on every node, i.e. to switch A8
|
||||||
// the genesis binding off across the whole fleet in order to cross a PLANNED rotation.
|
// 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
|
// 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
|
// offered next is chainHead + 1, and PqRegistryBindingRule judges that header against the entry
|
||||||
@ -1873,13 +1872,13 @@ public final class PqRegistryHash {
|
|||||||
|
|
||||||
if (active.isEmpty()) {
|
if (active.isEmpty()) {
|
||||||
final ScheduleEntry first = schedule.entries.get(0);
|
final ScheduleEntry first = schedule.entries.get(0);
|
||||||
// AERE HELD-SET SCOPE: ask the SET, not only the primary. A node staged for the activation may already
|
// AERE D-A: 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.
|
// hold the activation registry as history while still signing under the current one.
|
||||||
final Registry staged = set.forEntryBlock(first.block());
|
final Registry staged = set.forEntryBlock(first.block());
|
||||||
final String computed = registry == null ? null : hashFor(registry, chainId);
|
final String computed = registry == null ? null : hashFor(registry, chainId);
|
||||||
if (staged != null) {
|
if (staged != null) {
|
||||||
LOG.info(
|
LOG.info(
|
||||||
"AERE PQC GENESIS-BINDING: registry binding is scheduled to start at block {} and this node's chain "
|
"AERE PQC A8: registry binding is scheduled to start at block {} and this node's chain "
|
||||||
+ "head is {}, so nothing is enforced yet. The registry already loaded ({}, {} "
|
+ "head is {}, so nothing is enforced yet. The registry already loaded ({}, {} "
|
||||||
+ "entries) ALREADY MATCHES the hash required from block {}: 0x{}. This node is "
|
+ "entries) ALREADY MATCHES the hash required from block {}: 0x{}. This node is "
|
||||||
+ "correctly staged for the activation.",
|
+ "correctly staged for the activation.",
|
||||||
@ -1891,7 +1890,7 @@ public final class PqRegistryHash {
|
|||||||
hashFor(staged, chainId));
|
hashFor(staged, chainId));
|
||||||
} else {
|
} else {
|
||||||
LOG.error(
|
LOG.error(
|
||||||
"AERE PQC GENESIS-BINDING: registry binding starts at block {} and this node's chain head is {}, so "
|
"AERE PQC A8: 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 "
|
+ "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 "
|
+ "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 "
|
+ "REFUSE the header at block {} (AERE-PQC-REG-BLOCK-01) and stop there. Install "
|
||||||
@ -1915,7 +1914,7 @@ public final class PqRegistryHash {
|
|||||||
if (set.count() == 0) {
|
if (set.count() == 0) {
|
||||||
throw new RegistryConfigException(
|
throw new RegistryConfigException(
|
||||||
"AERE-PQC-REG-MISMATCH-02",
|
"AERE-PQC-REG-MISMATCH-02",
|
||||||
"AERE PQC GENESIS-BINDING: REFUSING TO START.\n"
|
"AERE PQC A8: REFUSING TO START.\n"
|
||||||
+ " EXPECTED: a Falcon validator registry whose canonical hash is\n"
|
+ " EXPECTED: a Falcon validator registry whose canonical hash is\n"
|
||||||
+ " 0x"
|
+ " 0x"
|
||||||
+ required.hash()
|
+ required.hash()
|
||||||
@ -1954,14 +1953,14 @@ public final class PqRegistryHash {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final String computed = hashFor(registry, chainId);
|
final String computed = hashFor(registry, chainId);
|
||||||
// AERE HELD-SET SCOPE (2026-08-06): the binding is satisfied by ANY file this node holds for this entry,
|
// AERE D-A (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
|
// 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
|
// alone, and after the first rotation the primary is by definition NOT the file that answers for
|
||||||
// the interval below the rotation.
|
// the interval below the rotation.
|
||||||
final Registry bound = set.forEntryBlock(required.block());
|
final Registry bound = set.forEntryBlock(required.block());
|
||||||
if (bound != null) {
|
if (bound != null) {
|
||||||
LOG.info(
|
LOG.info(
|
||||||
"AERE PQC GENESIS-BINDING: registry binding SATISFIED. Loaded {} ({}, {} entries, address-bound={}); "
|
"AERE PQC A8: registry binding SATISFIED. Loaded {} ({}, {} entries, address-bound={}); "
|
||||||
+ "canonical hash 0x{} equals the hash required from block {} by genesis "
|
+ "canonical hash 0x{} equals the hash required from block {} by genesis "
|
||||||
+ "config.pqRegistryHash, read from [{}]. Chain head {}, chainId {}. This node holds "
|
+ "config.pqRegistryHash, read from [{}]. Chain head {}, chainId {}. This node holds "
|
||||||
+ "{} registry file(s) in total. {}",
|
+ "{} registry file(s) in total. {}",
|
||||||
@ -1971,7 +1970,7 @@ public final class PqRegistryHash {
|
|||||||
bound.addressBound(),
|
bound.addressBound(),
|
||||||
hashFor(bound, chainId),
|
hashFor(bound, chainId),
|
||||||
required.block(),
|
required.block(),
|
||||||
// AERE GENESIS BINDING: naming the PROVENANCE of the schedule is not decoration. The whole defect
|
// AERE A8: 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
|
// 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.
|
// binding is satisfied without saying what it was read from asserts more than it knows.
|
||||||
schedule.source(),
|
schedule.source(),
|
||||||
@ -2001,7 +2000,7 @@ public final class PqRegistryHash {
|
|||||||
|
|
||||||
throw new RegistryConfigException(
|
throw new RegistryConfigException(
|
||||||
"AERE-PQC-REG-MISMATCH-01",
|
"AERE-PQC-REG-MISMATCH-01",
|
||||||
"AERE PQC GENESIS-BINDING: REFUSING TO START - the Falcon validator registry on this node is NOT the one "
|
"AERE PQC A8: REFUSING TO START - the Falcon validator registry on this node is NOT the one "
|
||||||
+ "this chain requires.\n"
|
+ "this chain requires.\n"
|
||||||
+ " EXPECTED hash: 0x"
|
+ " EXPECTED hash: 0x"
|
||||||
+ required.hash()
|
+ required.hash()
|
||||||
@ -2053,7 +2052,7 @@ public final class PqRegistryHash {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AERE HELD-SET SCOPE. Every registry file this node holds and which scheduled interval each one answers for.
|
* AERE D-A. 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
|
* 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
|
* it is the wrong one" from "I gave it four and none covers this height", which are different
|
||||||
* mistakes with different fixes.
|
* mistakes with different fixes.
|
||||||
@ -2097,12 +2096,11 @@ public final class PqRegistryHash {
|
|||||||
b.append(" entries : ").append(registry.count()).append('\n');
|
b.append(" entries : ").append(registry.count()).append('\n');
|
||||||
b.append(" addressBound: ").append(registry.addressBound()).append('\n');
|
b.append(" addressBound: ").append(registry.addressBound()).append('\n');
|
||||||
b.append(" format: ")
|
b.append(" format: ")
|
||||||
.append(registry.proofBound() ? "v2, binding proofs present" : "v1, NO binding proofs")
|
.append(registry.proofBound() ? "v2, D-146 binding proofs present" : "v1, NO binding proofs")
|
||||||
.append('\n');
|
.append('\n');
|
||||||
// GENESIS BINDING: the report printed both v1 and v2 without saying WHICH one goes into
|
// A8: the report printed both v1 and v2 without saying WHICH one goes into genesis, and
|
||||||
// genesis, and whoever took the last value off the screen took v1 and halted the fleet at
|
// whoever took the last value off the screen took v1 and halted the fleet at H-1. The one
|
||||||
// H-1. The one that matters is now named explicitly, and it is the very one the gate
|
// that matters is now named explicitly, and it is the very one the gate compares: hashFor.
|
||||||
// compares: hashFor.
|
|
||||||
b.append(" >>> FOR config.pqRegistryHash: 0x")
|
b.append(" >>> FOR config.pqRegistryHash: 0x")
|
||||||
.append(hashFor(registry, chainId))
|
.append(hashFor(registry, chainId))
|
||||||
.append(" <<< this one, and only this one\n");
|
.append(" <<< this one, and only this one\n");
|
||||||
@ -2147,7 +2145,7 @@ public final class PqRegistryHash {
|
|||||||
} catch (final IOException e) {
|
} catch (final IOException e) {
|
||||||
throw new RegistryConfigException(
|
throw new RegistryConfigException(
|
||||||
"AERE-PQC-REG-LOAD-13",
|
"AERE-PQC-REG-LOAD-13",
|
||||||
"AERE PQC GENESIS-BINDING: cannot read or parse JSON at '"
|
"AERE PQC A8: cannot read or parse JSON at '"
|
||||||
+ path
|
+ path
|
||||||
+ "': "
|
+ "': "
|
||||||
+ e
|
+ e
|
||||||
@ -2165,7 +2163,7 @@ public final class PqRegistryHash {
|
|||||||
} catch (final NumberFormatException e) {
|
} catch (final NumberFormatException e) {
|
||||||
throw new RegistryConfigException(
|
throw new RegistryConfigException(
|
||||||
"AERE-PQC-REG-LOAD-14",
|
"AERE-PQC-REG-LOAD-14",
|
||||||
"AERE PQC GENESIS-BINDING: registry '"
|
"AERE PQC A8: registry '"
|
||||||
+ source
|
+ source
|
||||||
+ "' has the unrecognised key '"
|
+ "' has the unrecognised key '"
|
||||||
+ raw
|
+ raw
|
||||||
@ -2177,7 +2175,7 @@ public final class PqRegistryHash {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AERE ROW BINDING. Parse an OPTIONAL non-negative header field: absent means -1, present means it must
|
* AERE D-146. 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,
|
* 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.
|
* because a default is how a threshold quietly becomes zero.
|
||||||
*/
|
*/
|
||||||
@ -2192,7 +2190,7 @@ public final class PqRegistryHash {
|
|||||||
} catch (final NumberFormatException e) {
|
} catch (final NumberFormatException e) {
|
||||||
throw new RegistryConfigException(
|
throw new RegistryConfigException(
|
||||||
"AERE-PQC-REG-LOAD-23",
|
"AERE-PQC-REG-LOAD-23",
|
||||||
"AERE PQC ROW-BINDING: registry '"
|
"AERE PQC D-146: registry '"
|
||||||
+ source
|
+ source
|
||||||
+ "' has "
|
+ "' has "
|
||||||
+ what
|
+ what
|
||||||
@ -2203,7 +2201,7 @@ public final class PqRegistryHash {
|
|||||||
if (v < 0) {
|
if (v < 0) {
|
||||||
throw new RegistryConfigException(
|
throw new RegistryConfigException(
|
||||||
"AERE-PQC-REG-LOAD-23",
|
"AERE-PQC-REG-LOAD-23",
|
||||||
"AERE PQC ROW-BINDING: registry '"
|
"AERE PQC D-146: registry '"
|
||||||
+ source
|
+ source
|
||||||
+ "' has a negative "
|
+ "' has a negative "
|
||||||
+ what
|
+ what
|
||||||
@ -2224,7 +2222,7 @@ public final class PqRegistryHash {
|
|||||||
} catch (final NumberFormatException e) {
|
} catch (final NumberFormatException e) {
|
||||||
throw new RegistryConfigException(
|
throw new RegistryConfigException(
|
||||||
"AERE-PQC-REG-LOAD-15",
|
"AERE-PQC-REG-LOAD-15",
|
||||||
"AERE PQC GENESIS-BINDING: registry '"
|
"AERE PQC A8: registry '"
|
||||||
+ source
|
+ source
|
||||||
+ "' has a malformed "
|
+ "' has a malformed "
|
||||||
+ what
|
+ what
|
||||||
@ -2249,7 +2247,7 @@ public final class PqRegistryHash {
|
|||||||
if (s.isEmpty() || (s.length() & 1) == 1) {
|
if (s.isEmpty() || (s.length() & 1) == 1) {
|
||||||
throw new RegistryConfigException(
|
throw new RegistryConfigException(
|
||||||
"AERE-PQC-REG-LOAD-16",
|
"AERE-PQC-REG-LOAD-16",
|
||||||
"AERE PQC GENESIS-BINDING: registry '"
|
"AERE PQC A8: registry '"
|
||||||
+ source
|
+ source
|
||||||
+ "' "
|
+ "' "
|
||||||
+ what
|
+ what
|
||||||
@ -2267,7 +2265,7 @@ public final class PqRegistryHash {
|
|||||||
if (hi < 0 || lo < 0) {
|
if (hi < 0 || lo < 0) {
|
||||||
throw new RegistryConfigException(
|
throw new RegistryConfigException(
|
||||||
"AERE-PQC-REG-LOAD-17",
|
"AERE-PQC-REG-LOAD-17",
|
||||||
"AERE PQC GENESIS-BINDING: registry '"
|
"AERE PQC A8: registry '"
|
||||||
+ source
|
+ source
|
||||||
+ "' "
|
+ "' "
|
||||||
+ what
|
+ what
|
||||||
@ -2289,7 +2287,7 @@ public final class PqRegistryHash {
|
|||||||
if (s.length() != 64) {
|
if (s.length() != 64) {
|
||||||
throw new RegistryConfigException(
|
throw new RegistryConfigException(
|
||||||
"AERE-PQC-REG-SCHED-06",
|
"AERE-PQC-REG-SCHED-06",
|
||||||
"AERE PQC GENESIS-BINDING: "
|
"AERE PQC A8: "
|
||||||
+ source
|
+ source
|
||||||
+ " carries the hash '"
|
+ " carries the hash '"
|
||||||
+ raw
|
+ raw
|
||||||
@ -2301,7 +2299,7 @@ public final class PqRegistryHash {
|
|||||||
if (Character.digit(s.charAt(i), 16) < 0) {
|
if (Character.digit(s.charAt(i), 16) < 0) {
|
||||||
throw new RegistryConfigException(
|
throw new RegistryConfigException(
|
||||||
"AERE-PQC-REG-SCHED-07",
|
"AERE-PQC-REG-SCHED-07",
|
||||||
"AERE PQC GENESIS-BINDING: " + source + " hash '" + raw + "' is not hexadecimal. Refusing to start.");
|
"AERE PQC A8: " + source + " hash '" + raw + "' is not hexadecimal. Refusing to start.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return s;
|
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
|
* 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
|
* the License. You may obtain a copy of the License at
|
||||||
@ -23,8 +23,8 @@ import java.util.Map;
|
|||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* REGISTRY SCHEDULE TOOL. The tool that writes and checks the two configuration values without
|
* D-145. The tool that writes and checks the two configuration values without which the D2 repair
|
||||||
* which the height-indexed registry repair changes nothing.
|
* changes nothing.
|
||||||
*
|
*
|
||||||
* <p>WHY IT LIVES IN THE CONSENSUS MODULE AND NOT IN A SCRIPT. The value being computed is a
|
* <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
|
* keccak digest over a domain-separated, length-prefixed, chain-bound pre-image, and the node
|
||||||
@ -36,8 +36,8 @@ import java.util.Optional;
|
|||||||
*
|
*
|
||||||
* <pre>
|
* <pre>
|
||||||
* java -cp 'besu/lib/*' org.hyperledger.besu.consensus.common.bft.PqRegistryHashTool \
|
* java -cp 'besu/lib/*' org.hyperledger.besu.consensus.common.bft.PqRegistryHashTool \
|
||||||
* verify --chain-id 2800 --genesis <config-dir>/genesis.json \
|
* verify --chain-id 2800 --genesis ./genesis-2800.json \
|
||||||
* --history <config-dir>/falcon/registry-epoca-0.properties
|
* --history ./falcon/registry-epoch-0.properties
|
||||||
* </pre>
|
* </pre>
|
||||||
*
|
*
|
||||||
* <p>THREE VERBS.
|
* <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)");
|
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) {
|
for (final Path p : files) {
|
||||||
final PqRegistryHash.Registry r = PqRegistryHash.loadAuto(p);
|
final PqRegistryHash.Registry r = PqRegistryHash.loadAuto(p);
|
||||||
// AERE CANONICAL FINGERPRINT (2026-08-06): hashFor, not hashV1. For a registry that carries
|
// AERE D-C (2026-08-06): hashFor, not hashV1. For a registry that carries binding proofs,
|
||||||
// binding proofs, hashV1 is a number NOTHING in the node ever compares against: the guard
|
// hashV1 is a number NOTHING in the node ever compares against: the guard compares hashFor,
|
||||||
// compares hashFor, that is hashV2. The same mistake, in the `generate` verb below, writes
|
// that is hashV2. The same mistake, in the `generate` verb below, writes into genesis a hash
|
||||||
// into genesis a hash the node will never recognise, and then all seven start and the chain
|
// the node will never recognise, and then all seven start and the chain stops at H-1.
|
||||||
// stops at H-1. On top of that, hashV1 does NOT tell two rotation epochs of the same fleet
|
// On top of that, hashV1 does NOT tell two rotation epochs of the same fleet apart, because the
|
||||||
// apart, because the bind height does not enter the v1 pre-image; so it cannot serve even as
|
// bind height does not enter the v1 pre-image; so it cannot serve even as an epoch identifier
|
||||||
// an epoch identifier for diagnostics.
|
// for diagnostics.
|
||||||
System.out.println(
|
System.out.println(
|
||||||
"0x"
|
"0x"
|
||||||
+ PqRegistryHash.hashFor(r, chainId)
|
+ PqRegistryHash.hashFor(r, chainId)
|
||||||
@ -146,8 +146,8 @@ public final class PqRegistryHashTool {
|
|||||||
final String armingHeightRaw = o.get("arming-height");
|
final String armingHeightRaw = o.get("arming-height");
|
||||||
if (armingHeightRaw == null) {
|
if (armingHeightRaw == null) {
|
||||||
System.out.println("NOT MEASURED: --arming-height <H> is missing. The first entry of the");
|
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: a later");
|
System.out.println(" schedule must be EXACTLY at aere.pq.anchorBlock; see case D");
|
||||||
System.out.println(" first entry leaves the arming height with no scheduled registry.");
|
System.out.println(" of dovezi-d2-2026-08-06.");
|
||||||
return 2;
|
return 2;
|
||||||
}
|
}
|
||||||
final long h = Long.parseLong(armingHeightRaw);
|
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
|
// 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
|
// 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
|
// [AERE-PQC-REG-LOAD-13] "Unexpected character ('/')", and it then refuses EVERY header from
|
||||||
// the arming height upwards. Measured 2026-08-06, with two controls that reproduce the refusal.
|
// the arming height upwards. Measured 2026-08-06, cases U3/U4 in d2-v2/dovezi/controale/.
|
||||||
// Whatever is explanation is printed outside the JSON, on lines beginning with #.
|
// Whatever is explanation is printed outside the JSON, on lines beginning with #.
|
||||||
final StringBuilder json = new StringBuilder();
|
final StringBuilder json = new StringBuilder();
|
||||||
json.append(" \"pqRegistryHash\": [\n");
|
json.append(" \"pqRegistryHash\": [\n");
|
||||||
@ -190,17 +190,17 @@ public final class PqRegistryHashTool {
|
|||||||
final long b = inaltimi.get(i);
|
final long b = inaltimi.get(i);
|
||||||
final Path p = epoci.get(b);
|
final Path p = epoci.get(b);
|
||||||
final PqRegistryHash.Registry reg = PqRegistryHash.loadAuto(p);
|
final PqRegistryHash.Registry reg = PqRegistryHash.loadAuto(p);
|
||||||
// AERE CANONICAL FINGERPRINT (2026-08-06). THIS IS THE DANGEROUS VERB: what is printed here
|
// AERE D-C (2026-08-06). THIS IS THE DANGEROUS VERB: what is printed here gets pasted into
|
||||||
// gets pasted into genesis, and genesis is the document all seven nodes hold identical.
|
// genesis, and genesis is the document all seven nodes hold identical. hashV1 next to a
|
||||||
// hashV1 next to a registry that carries proofs writes into genesis a number the node's guard
|
// registry that carries proofs writes into genesis a number the node's guard (hashFor) never
|
||||||
// (hashFor) never produces, so all seven nodes start, all report the registry loaded, and the
|
// produces, so all seven nodes start, all report the registry loaded, and the chain stops at
|
||||||
// chain stops at H-1. A wrong indication is more dangerous than a missing one.
|
// H-1. A wrong indication is more dangerous than a missing one.
|
||||||
final String hash = PqRegistryHash.hashFor(reg, chainId);
|
final String hash = PqRegistryHash.hashFor(reg, chainId);
|
||||||
// AERE SIGNED-HEIGHT CHECK (2026-08-06). The recipe we print has to be the one the NEW code
|
// AERE D-B (2026-08-06). The recipe we print has to be the one the NEW code accepts. Since
|
||||||
// accepts. Since 2026-08-06 the node refuses to start (AERE-PQC-REG-BIND-08) on a registry
|
// 2026-08-06 the node refuses to start (AERE-PQC-REG-BIND-08) on a registry forced in at a
|
||||||
// forced in at a height its proofs did not sign. If the tool printed that recipe, it would
|
// height its proofs did not sign. If the tool printed that recipe, it would manufacture
|
||||||
// manufacture exactly the configuration the node rejects, and it would do so in a file that
|
// exactly the configuration the node rejects, and it would do so in a file that reaches all
|
||||||
// reaches all seven at once.
|
// seven at once.
|
||||||
if (reg.proofBound() && reg.bindHeight() != b) {
|
if (reg.proofBound() && reg.bindHeight() != b) {
|
||||||
System.out.println(
|
System.out.println(
|
||||||
"RED: registry "
|
"RED: registry "
|
||||||
@ -271,10 +271,10 @@ public final class PqRegistryHashTool {
|
|||||||
System.out.println(
|
System.out.println(
|
||||||
"RED: "
|
"RED: "
|
||||||
+ genesis
|
+ genesis
|
||||||
+ " does not carry config.pqRegistryHash. The height-indexed registry schedule "
|
+ " does not carry config.pqRegistryHash. This is D-145 exactly as it was measured: "
|
||||||
+ "exists, it is complete, and it is OFF. A node armed without a schedule falls back "
|
+ "the machinery exists, it is complete, and it is OFF. A node armed without a "
|
||||||
+ "on TODAY's registry and says ACCEPTED for a header it has bound to no "
|
+ "schedule falls back on TODAY's registry and says ACCEPTED for a header it has "
|
||||||
+ "height.");
|
+ "bound to no height (case D of dovezi-d2-2026-08-06).");
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -311,10 +311,9 @@ public final class PqRegistryHashTool {
|
|||||||
}
|
}
|
||||||
System.out.println("registries held = " + set.count());
|
System.out.println("registries held = " + set.count());
|
||||||
for (final Path p : history) {
|
for (final Path p : history) {
|
||||||
// AERE CANONICAL FINGERPRINT: hashFor. This line sits immediately under the list of scheduled
|
// AERE D-C: hashFor. This line sits immediately under the list of scheduled epochs printed
|
||||||
// epochs printed with their hashes; two numbers laid one under the other so that they get
|
// with their hashes; two numbers laid one under the other so that they get compared by eye,
|
||||||
// compared by eye, and computed with two different functions, are a comparison that can never
|
// and computed with two different functions, are a comparison that can never match.
|
||||||
// match.
|
|
||||||
final PqRegistryHash.Registry r = PqRegistryHash.loadAuto(p);
|
final PqRegistryHash.Registry r = PqRegistryHash.loadAuto(p);
|
||||||
System.out.println(
|
System.out.println(
|
||||||
" "
|
" "
|
||||||
@ -325,9 +324,9 @@ public final class PqRegistryHashTool {
|
|||||||
}
|
}
|
||||||
final String armareRaw = o.get("arming-height");
|
final String armareRaw = o.get("arming-height");
|
||||||
int rc = 0;
|
int rc = 0;
|
||||||
// AERE SIGNED-HEIGHT CHECK: the registries that reproduce the required hash and did NOT sign
|
// AERE D-B: the registries that reproduce the required hash and did NOT sign that height. This
|
||||||
// that height. This is exactly what the node now refuses to start on; until 2026-08-06 the node
|
// is exactly what the node now refuses to start on; until 2026-08-06 the node started and the
|
||||||
// started and the tool said nothing.
|
// tool said nothing.
|
||||||
for (final PqRegistryHash.Misbound m : set.misbound()) {
|
for (final PqRegistryHash.Misbound m : set.misbound()) {
|
||||||
System.out.println(
|
System.out.println(
|
||||||
"RED: "
|
"RED: "
|
||||||
@ -341,11 +340,11 @@ public final class PqRegistryHashTool {
|
|||||||
}
|
}
|
||||||
if (armareRaw != null) {
|
if (armareRaw != null) {
|
||||||
final long h = Long.parseLong(armareRaw);
|
final long h = Long.parseLong(armareRaw);
|
||||||
final long prima = schedule.entries().get(0).block();
|
final long first = schedule.entries().get(0).block();
|
||||||
if (prima != h) {
|
if (first != h) {
|
||||||
System.out.println(
|
System.out.println(
|
||||||
"RED: the first entry of the schedule is at "
|
"RED: the first entry of the schedule is at "
|
||||||
+ prima
|
+ first
|
||||||
+ ", while aere.pq.anchorBlock is "
|
+ ", while aere.pq.anchorBlock is "
|
||||||
+ h
|
+ h
|
||||||
+ ". They must be EQUAL. If the first entry is higher, the heights between H and it "
|
+ ". They must be EQUAL. If the first entry is higher, the heights between H and it "
|
||||||
@ -429,9 +428,9 @@ public final class PqRegistryHashTool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<Path> files(final Map<String, String> o, final String cheie) {
|
private static List<Path> files(final Map<String, String> o, final String key) {
|
||||||
final List<Path> out = new ArrayList<>();
|
final List<Path> out = new ArrayList<>();
|
||||||
for (final String s : list(o.get(cheie))) {
|
for (final String s : list(o.get(key))) {
|
||||||
out.add(Path.of(s));
|
out.add(Path.of(s));
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
@ -469,7 +468,7 @@ public final class PqRegistryHashTool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static void utilizare() {
|
private static void utilizare() {
|
||||||
System.out.println("The height-indexed registry schedule: hash, generate, verify.");
|
System.out.println("D-145. The height-indexed registry schedule: hash, generate, verify.");
|
||||||
System.out.println();
|
System.out.println();
|
||||||
System.out.println(" hash --chain-id 2800 --registry <f>[,<f>...]");
|
System.out.println(" hash --chain-id 2800 --registry <f>[,<f>...]");
|
||||||
System.out.println(" generate --chain-id 2800 --arming-height <H> --registry <f>");
|
System.out.println(" generate --chain-id 2800 --arming-height <H> --registry <f>");
|
||||||
|
|||||||
@ -0,0 +1,139 @@
|
|||||||
|
/*
|
||||||
|
* 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
|
* 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
|
* the License. You may obtain a copy of the License at
|
||||||
@ -52,13 +52,12 @@ import org.slf4j.LoggerFactory;
|
|||||||
* amount of waiting healed it. The seals over M(head) exist nowhere else: they travel only on the
|
* 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.
|
* Commit messages of the head block, and those are never replayed.
|
||||||
*
|
*
|
||||||
* <p>AND IT IS NOT THE UNBOUND-REGISTRY DEFECT IN ANOTHER COAT. That one was a registry of public
|
* <p>AND IT IS NOT DEFECT A8 IN ANOTHER COAT. A8 was a registry of public keys read from a file and
|
||||||
* keys read from a file and BELIEVED. Every seal read back here is re-verified, cryptographically,
|
* BELIEVED. Every seal read back here is re-verified, cryptographically, against the anchored
|
||||||
* against the anchored registry over M rebuilt from the header this process just loaded - see
|
* registry over M rebuilt from the header this process just loaded - see {@link PqSealStore}. A
|
||||||
* {@link PqSealStore}. A forged file cannot inject a seal without forging a Falcon-512 signature;
|
* forged file cannot inject a seal without forging a Falcon-512 signature; the worst it achieves is
|
||||||
* the worst it achieves is the empty cache an absent file already gives. Persistence is OFF unless
|
* the empty cache an absent file already gives. Persistence is OFF unless a caller enables it, and
|
||||||
* a caller enables it, and the only caller that does is the QBFT controller builder, only when the
|
* the only caller that does is the QBFT controller builder, only when the anchor is actually armed.
|
||||||
* anchor is actually armed.
|
|
||||||
*
|
*
|
||||||
* <p>WHAT IT DOES NOT DO. The in-memory path verifies nothing. Whether a seal is valid, whether its
|
* <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
|
* 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
|
* 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
|
* 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
|
* and nobody could propose. Seals come from Commits, Commits come from proposals, proposals need
|
||||||
* seals - the same circle, one level down.
|
* seals - the same circle, one level down.
|
||||||
*
|
*
|
||||||
* <p><b>WHY THIS IS NOT THE UNBOUND-REGISTRY DEFECT IN ANOTHER COAT, and the distinction is the
|
* <p><b>WHY THIS IS NOT DEFECT A8 IN ANOTHER COAT, and the distinction is the whole safety
|
||||||
* whole safety argument.</b> That one was a REGISTRY read from a local file and BELIEVED: the
|
* argument.</b> A8 was a REGISTRY read from a local file and BELIEVED: the public keys that decide
|
||||||
* public keys that decide who is a legitimate signer came out of a file a node could be pointed at
|
* who is a legitimate signer came out of a file a node could be pointed at wrongly, so the file was
|
||||||
* wrongly, so the file was authority. Nothing here is believed. A Falcon seal is SELF-AUTHENTICATING: {@link
|
* 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
|
* #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
|
* 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
|
* 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
|
if (seal.getValidatorIndex() < 0
|
||||||
|| seal.getSignature() == null
|
|| seal.getSignature() == null
|
||||||
|| !seen.add(seal.getValidatorIndex())
|
|| !seen.add(seal.getValidatorIndex())
|
||||||
// HEIGHT-RESOLVED LOOKUP (2026-08-06). These seals are over block `blockNumber`, which the
|
// D2 (2026-08-06): height-resolved. These seals are over block `blockNumber`, which the
|
||||||
// caller has already matched against the stored header, so the height is known exactly.
|
// 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
|
// 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.
|
// that it can no longer resolve to a DIFFERENT one without saying so.
|
||||||
// OWN-HEAD DOOR (b-v2): the caller has already refused this file unless the
|
// D2 (b-v2): the OWN-HEAD door. 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
|
// stored block number and hash equal this node's head, so the subject is this node's
|
||||||
// own head by construction.
|
// own head by construction.
|
||||||
|| registry.addressForIndexAtOwnHead(blockNumber, seal.getValidatorIndex()) == null) {
|
|| registry.addressForIndexAtOwnHead(blockNumber, seal.getValidatorIndex()) == null) {
|
||||||
@ -359,8 +359,7 @@ public final class PqSealStore {
|
|||||||
* makes when it decides which heard seals may enter a certificate. Nothing about a seal is trusted
|
* makes when it decides which heard seals may enter a certificate. Nothing about a seal is trusted
|
||||||
* because it was on disk.
|
* because it was on disk.
|
||||||
*
|
*
|
||||||
* <p>HEIGHT-RESOLVED LOOKUP (2026-08-06): it now carries the HEIGHT the seals belong to. The
|
* <p>D2 (2026-08-06): it now carries the HEIGHT the seals belong to. The adversarial review of
|
||||||
* adversarial review of
|
|
||||||
* 2026-08-02 measured that every registry question in this stack was height-less, so a restart
|
* 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
|
* 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
|
* 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
|
* 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
|
* the License. You may obtain a copy of the License at
|
||||||
@ -37,18 +37,17 @@ import org.apache.tuweni.bytes.Bytes;
|
|||||||
public interface PqSignerRegistry {
|
public interface PqSignerRegistry {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HEIGHT-INDEXED REGISTRY, LOOKUP HARDENING (a). The validator address bound to a registry index
|
* D-081 / D2 HARDENING (a). The validator address bound to a registry index AT A HEIGHT.
|
||||||
* AT A HEIGHT.
|
|
||||||
*
|
*
|
||||||
* <p>WHY THERE IS NO HEIGHT-LESS FORM HERE, and why this is the change and not a nicety. Until
|
* <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
|
* 2026-08-06 this interface carried BOTH {@code addressForIndex(int)} and a {@code
|
||||||
* addressForIndexAt(long,int)} whose body was {@code default { return addressForIndex(idx); }}.
|
* addressForIndexAt(long,int)} whose body was {@code default { return addressForIndex(idx); }}.
|
||||||
* That default is precisely the defect an adversarial review of 2026-08-02 measured: a registry
|
* That default is precisely what the adversarial review of 2026-08-02 measured as D2: a registry
|
||||||
* has no height argument, so a header that verified yesterday is refused the moment index 0's
|
* 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 - that review's
|
* Falcon key is rotated. The default made the defect INVISIBLE TO ITS OWN PROOF - the D2 harness
|
||||||
* probe injected a registry that overrides only the height-less pair, inherits the default, and
|
* (adversar-2026-08-02/harness/RuleProbe.java lines 115-131) injects a registry that overrides
|
||||||
* therefore returns exactly the same verdict on repaired and unrepaired code. A probe that cannot
|
* only the height-less pair, inherits the default, and therefore returns exactly the same verdict
|
||||||
* go red is not a probe.
|
* 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.
|
* <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
|
* The compiler is now the negative control: any implementation, test double included, that cannot
|
||||||
@ -56,7 +55,7 @@ public interface PqSignerRegistry {
|
|||||||
* on {@link FalconSealSupport} and under a name that cannot be mistaken for a verification path -
|
* on {@link FalconSealSupport} and under a name that cannot be mistaken for a verification path -
|
||||||
* see {@code FalconSealSupport.localSigningAddress()}.
|
* see {@code FalconSealSupport.localSigningAddress()}.
|
||||||
*
|
*
|
||||||
* <p>LOOKUP HARDENING (b-v2), 2026-08-06: this is the HISTORY half of the pair. It is reachable
|
* <p>D2 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
|
* 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
|
* arming height. The own-head half is {@link #addressForIndexAtOwnHead}, which carries the
|
||||||
* measurement that forced the split.
|
* measurement that forced the split.
|
||||||
@ -69,18 +68,16 @@ public interface PqSignerRegistry {
|
|||||||
Address addressForIndexAtHistoric(long blockNumber, int validatorIndex);
|
Address addressForIndexAtHistoric(long blockNumber, int validatorIndex);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* LOOKUP HARDENING (b-v2). The address bound to a registry index at a height, asked about THIS
|
* D2 HARDENING (b-v2). The address bound to a registry index at a height, asked about THIS NODE'S
|
||||||
* NODE'S
|
|
||||||
* OWN HEAD: a block this node is building, or the head it has just restarted onto.
|
* 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
|
* <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
|
* 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
|
* 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
|
* PqSealPersistenceTest}, the restart path, and one in {@code PqForkValidatorSetChangeTest}, the
|
||||||
* proposer - and that test's message states the operational consequence in one line: the node
|
* proposer - and the D078 message states the operational consequence in one line: the node stops
|
||||||
* stops producing blocks. In all six the number handed to the guard was 1030 with an arming
|
* producing blocks. In all six the number handed to the guard was 1030 with an arming height of
|
||||||
* height of 1000, which is exactly what a genuinely historical question at the same instant would
|
* 1000, which is exactly what a genuinely historical question at the same instant would hand it.
|
||||||
* hand it.
|
|
||||||
* There is no arithmetic on the height that separates the two. What separates them is WHO SUPPLIES
|
* 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.
|
* THE SUBJECT, and that is known at every call site and was being thrown away at the boundary.
|
||||||
*
|
*
|
||||||
@ -99,8 +96,7 @@ public interface PqSignerRegistry {
|
|||||||
Address addressForIndexAtOwnHead(long blockNumber, int validatorIndex);
|
Address addressForIndexAtOwnHead(long blockNumber, int validatorIndex);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HEIGHT-INDEXED REGISTRY, LOOKUP HARDENING (a). Verify a Falcon signature by a registry index AT
|
* D-081 / D2 HARDENING (a). Verify a Falcon signature by a registry index AT A HEIGHT. Must never
|
||||||
* A HEIGHT. Must never
|
|
||||||
* throw. Abstract for the reason given on {@link #addressForIndexAtHistoric}.
|
* throw. Abstract for the reason given on {@link #addressForIndexAtHistoric}.
|
||||||
*
|
*
|
||||||
* @param blockNumber the height of the header carrying the seal
|
* @param blockNumber the height of the header carrying the seal
|
||||||
@ -112,8 +108,7 @@ public interface PqSignerRegistry {
|
|||||||
boolean verifyAtHistoric(long blockNumber, int validatorIndex, Bytes message, Bytes signature);
|
boolean verifyAtHistoric(long blockNumber, int validatorIndex, Bytes message, Bytes signature);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* LOOKUP HARDENING (b-v2). Verify a Falcon signature over a block THIS NODE holds as its own head
|
* D2 HARDENING (b-v2). Verify a Falcon signature over a block THIS NODE holds as its own head or
|
||||||
* or
|
|
||||||
* is building right now. Never refuses for a missing height binding; see {@link
|
* 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
|
* #addressForIndexAtOwnHead} for the measurement that forced the split and for what it still does
|
||||||
* refuse.
|
* refuse.
|
||||||
|
|||||||
@ -0,0 +1,69 @@
|
|||||||
|
/* 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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,96 @@
|
|||||||
|
/*
|
||||||
|
* 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) {}
|
||||||
|
}
|
||||||
@ -0,0 +1,53 @@
|
|||||||
|
/* 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,123 @@
|
|||||||
|
/* 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
|
* 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
|
* 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);
|
final int sealCap = cfg.maxSealsCarried().orElse(Integer.MAX_VALUE);
|
||||||
int rejectedIneligible = 0;
|
int rejectedIneligible = 0;
|
||||||
int rejectedInvalid = 0;
|
int rejectedInvalid = 0;
|
||||||
// HEIGHT-RESOLVED LOOKUP (2026-08-06): the proposer resolves keys AT THE PARENT'S HEIGHT, the
|
// D2 (2026-08-06): the proposer resolves keys AT THE PARENT'S HEIGHT, the same height R2 will
|
||||||
// same height R2 will use when it re-checks this certificate. Before this the producer asked a
|
// use when it re-checks this certificate. Before this the producer asked a height-less registry
|
||||||
// height-less registry while R2 asked a height-resolved one, so at a rotation height the two
|
// while R2 asked a height-resolved one, so at a rotation height the two could disagree about
|
||||||
// could disagree about which key set applies - the proposer would assemble a certificate the
|
// which key set applies - the proposer would assemble a certificate the fleet then refuses, and
|
||||||
// fleet then refuses, and the round would fail for a reason no log named. Building and
|
// the round would fail for a reason no log named. Building and validating now read the same
|
||||||
// validating now read the same question.
|
// question.
|
||||||
for (final FalconSeal seal : PqAnchor.sortedByIndex(heard)) {
|
for (final FalconSeal seal : PqAnchor.sortedByIndex(heard)) {
|
||||||
// OWN-HEAD DOOR (b-v2): parentHeader is this node's own head - this method is
|
// D2 (b-v2): the OWN-HEAD door. 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
|
// 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.
|
// missing schedule is what stopped block production in PqForkValidatorSetChangeTest.
|
||||||
final Address signer =
|
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
|
// 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.
|
// 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
|
// 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 roughly 1.7 times the header
|
// seals, 36 carried 5, 5 carried 6. At 666 bytes a seal that is 200.9 GB per node per year
|
||||||
// bytes the same chain would write capped at K, and the extra buys NOTHING: what a verifier
|
// instead of 120.5, and the extra buys NOTHING: what a verifier demands is the threshold, not
|
||||||
// demands is the threshold, not how many seals a proposer volunteers above it.
|
// how many seals a proposer volunteers above it.
|
||||||
//
|
//
|
||||||
// The break is safe precisely because it is placed AFTER the eligibility and signature checks:
|
// 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
|
// 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;
|
import org.hyperledger.besu.consensus.common.bft.PqRegistryHash;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AERE GENESIS BINDING: compute the canonical hash of a Falcon validator registry so it can be put
|
* AERE A8: compute the canonical hash of a Falcon validator registry so it can be put into genesis
|
||||||
* into genesis as {@code config.pqRegistryHash}.
|
* as {@code config.pqRegistryHash}.
|
||||||
*
|
*
|
||||||
* <p>This tool calls THE SAME code the startup guard calls. That is the whole point of it existing
|
* <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
|
* 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// AERE CANONICAL FINGERPRINT (2026-08-06). hashFor, not hashV1, and this is the line an
|
// AERE D-C (2026-08-06). hashFor, not hashV1, and this is the line an operator pastes into
|
||||||
// operator pastes into genesis. For a proof-bound (v2) registry the node's gate compares
|
// genesis. For a proof-bound (v2) registry the node's gate compares hashV2; printing hashV1 here
|
||||||
// hashV2; printing hashV1 here gives the whole fleet a value NOTHING on a node ever computes.
|
// gives the whole fleet a value NOTHING on a node ever computes. Measured on a network of seven
|
||||||
// Measured on a network of seven on 2026-08-06: all seven start, all seven report the registry
|
// on 2026-08-06: all seven start, all seven report the registry loaded, and the chain stops at
|
||||||
// loaded, and the chain stops at H-1. A wrong instruction is more dangerous than a missing one.
|
// 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
|
// 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
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
System.out.println("AERE PQC GENESIS-BINDING - canonical Falcon registry hash");
|
System.out.println("AERE PQC A8 - canonical Falcon registry hash");
|
||||||
System.out.println(" file : " + reg.sourcePath());
|
System.out.println(" file : " + reg.sourcePath());
|
||||||
System.out.println(" source kind : " + reg.kind());
|
System.out.println(" source kind : " + reg.kind());
|
||||||
System.out.println(" entries : " + reg.count());
|
System.out.println(" entries : " + reg.count());
|
||||||
@ -130,7 +130,7 @@ public final class PqRegistryHashTool {
|
|||||||
if (reg.proofBound()) {
|
if (reg.proofBound()) {
|
||||||
System.out.println();
|
System.out.println();
|
||||||
System.out.println(
|
System.out.println(
|
||||||
" AERE HEIGHT BINDING: schedule this registry at block "
|
" AERE D-B: schedule this registry at block "
|
||||||
+ reg.bindHeight()
|
+ reg.bindHeight()
|
||||||
+ " AND NOWHERE ELSE. Every row's possession proof and validator claim sign that");
|
+ " AND NOWHERE ELSE. Every row's possession proof and validator claim sign that");
|
||||||
System.out.println(
|
System.out.println(
|
||||||
@ -160,10 +160,10 @@ public final class PqRegistryHashTool {
|
|||||||
System.out.println();
|
System.out.println();
|
||||||
System.out.println(" Paste into genesis under \"config\":");
|
System.out.println(" Paste into genesis under \"config\":");
|
||||||
System.out.println();
|
System.out.println();
|
||||||
// AERE HEIGHT BINDING: the recipe this tool prints has to be the recipe the node accepts.
|
// AERE D-B: the recipe this tool prints has to be the recipe the node accepts. Since 2026-08-06
|
||||||
// Since 2026-08-06 a proof-bound registry scheduled at a block other than its bindHeight is
|
// a proof-bound registry scheduled at a block other than its bindHeight is refused at startup,
|
||||||
// refused at startup, so printing one here would be manufacturing the configuration the node
|
// so printing one here would be manufacturing the configuration the node rejects - in a file
|
||||||
// rejects - in a file that goes to all seven nodes at once.
|
// that goes to all seven nodes at once.
|
||||||
final long at = block == 0L && reg.proofBound() ? reg.bindHeight() : block;
|
final long at = block == 0L && reg.proofBound() ? reg.bindHeight() : block;
|
||||||
if (reg.proofBound() && at != reg.bindHeight()) {
|
if (reg.proofBound() && at != reg.bindHeight()) {
|
||||||
System.err.println();
|
System.err.println();
|
||||||
|
|||||||
@ -0,0 +1,354 @@
|
|||||||
|
/*
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,428 @@
|
|||||||
|
/*
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,368 @@
|
|||||||
|
/*
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,484 @@
|
|||||||
|
/*
|
||||||
|
* 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,394 @@
|
|||||||
|
/*
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,621 @@
|
|||||||
|
/*
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,387 @@
|
|||||||
|
/*
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,466 @@
|
|||||||
|
/*
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,97 @@
|
|||||||
|
/*
|
||||||
|
* 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,386 @@
|
|||||||
|
/*
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,326 @@
|
|||||||
|
/*
|
||||||
|
* 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,10 +3,8 @@
|
|||||||
*
|
*
|
||||||
* WHY THIS EXISTS, and the cost that demanded it. On 8 August Falcon seal attachment was switched
|
* 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
|
* on across all seven validators of chain 2800. The header went from 525 to 3844 bytes, that is
|
||||||
* FIVE seals on EVERY block: more than seven times the previous header, every ~523 ms. On a chain
|
* FIVE seals on EVERY block. At ~60.3 million blocks per year that is ~200 GB per year per node,
|
||||||
* with no transactions the headers are close to everything that gets written to disk, so that
|
* measured, and the tightest host had 12 GB free. That is 23 days.
|
||||||
* 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
|
* 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
|
* a cap, built and proven on 7 August. But the assembler that runs when the anchor is NOT armed had
|
||||||
@ -27,7 +25,7 @@
|
|||||||
* and it is a dial, not an accident. Algorand ships the same shape at 1 in 256 or rarer.
|
* 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:
|
* 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 the full cost on the other.
|
* precisely because we had a proof on one of them only, we paid 200 GB per year on the other.
|
||||||
*/
|
*/
|
||||||
package org.hyperledger.besu.consensus.common.bft;
|
package org.hyperledger.besu.consensus.common.bft;
|
||||||
|
|
||||||
@ -95,23 +93,23 @@ class FalconAttachIntervalTest {
|
|||||||
// ---------------------------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------------------------
|
||||||
@Test
|
@Test
|
||||||
void anIntervalOfOneHundredCarriesExactlyOneHeightInOneHundred() {
|
void anIntervalOfOneHundredCarriesExactlyOneHeightInOneHundred() {
|
||||||
int cu = 0;
|
int with = 0;
|
||||||
for (long n = ATTACH; n < ATTACH + 10_000; n++) {
|
for (long n = ATTACH; n < ATTACH + 10_000; n++) {
|
||||||
if (FalconSealSupport.isAttachHeight(n, ATTACH, OptionalInt.of(100))) {
|
if (FalconSealSupport.isAttachHeight(n, ATTACH, OptionalInt.of(100))) {
|
||||||
cu++;
|
with++;
|
||||||
assertThat((n - ATTACH) % 100).as("height %d is not a multiple", n).isZero();
|
assertThat((n - ATTACH) % 100).as("height %d is not a multiple", n).isZero();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
assertThat(cu).isEqualTo(100);
|
assertThat(with).isEqualTo(100);
|
||||||
|
|
||||||
// and the same span of heights WITH NO interval, so the difference we are buying is visible
|
// and the same span of heights WITH NO interval, so the difference we are buying is visible
|
||||||
int fara = 0;
|
int without = 0;
|
||||||
for (long n = ATTACH; n < ATTACH + 10_000; n++) {
|
for (long n = ATTACH; n < ATTACH + 10_000; n++) {
|
||||||
if (FalconSealSupport.isAttachHeight(n, ATTACH, OptionalInt.empty())) {
|
if (FalconSealSupport.isAttachHeight(n, ATTACH, OptionalInt.empty())) {
|
||||||
fara++;
|
without++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
assertThat(fara).isEqualTo(10_000);
|
assertThat(without).isEqualTo(10_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------------------------
|
||||||
@ -207,23 +205,21 @@ class FalconAttachIntervalTest {
|
|||||||
@Test
|
@Test
|
||||||
void theMeasuredCostOfEachSettingIsWhatWeToldTheFounder() {
|
void theMeasuredCostOfEachSettingIsWhatWeToldTheFounder() {
|
||||||
final long blocuriPeAn = 60_300_000L;
|
final long blocuriPeAn = 60_300_000L;
|
||||||
final long octetiPeSigiliu = 662L;
|
final long bytesPerSeal = 662L;
|
||||||
|
|
||||||
assertThat(gbPeAn(5, 1, octetiPeSigiliu, blocuriPeAn)).isBetween(180L, 210L); // today
|
assertThat(gbPeAn(5, 1, bytesPerSeal, blocuriPeAn)).isBetween(180L, 210L); // today
|
||||||
assertThat(gbPeAn(3, 1, octetiPeSigiliu, blocuriPeAn)).isBetween(105L, 125L); // cap only
|
assertThat(gbPeAn(3, 1, bytesPerSeal, blocuriPeAn)).isBetween(105L, 125L); // cap only
|
||||||
assertThat(gbPeAn(3, 32, octetiPeSigiliu, blocuriPeAn)).isBetween(3L, 5L); // cap + 32
|
assertThat(gbPeAn(3, 32, bytesPerSeal, blocuriPeAn)).isBetween(3L, 5L); // cap + 32
|
||||||
assertThat(gbPeAn(3, 100, octetiPeSigiliu, blocuriPeAn)).isBetween(1L, 2L); // cap + 100
|
assertThat(gbPeAn(3, 100, bytesPerSeal, blocuriPeAn)).isBetween(1L, 2L); // cap + 100
|
||||||
|
|
||||||
// and the boundary that matters for any provisioning decision: starting from a fixed space
|
// and the boundary that matters for the disk decision: at 12 GB free, how many days are left
|
||||||
// budget, how many days each setting lasts. The budget below is a parameter of the proof, kept
|
assertThat(zile(12L, gbPeAn(5, 1, bytesPerSeal, blocuriPeAn))).isLessThan(30L);
|
||||||
// deliberately small so that the order of magnitude between the settings is visible.
|
assertThat(zile(12L, gbPeAn(3, 32, bytesPerSeal, blocuriPeAn))).isGreaterThan(700L);
|
||||||
assertThat(zile(12L, gbPeAn(5, 1, octetiPeSigiliu, blocuriPeAn))).isLessThan(30L);
|
|
||||||
assertThat(zile(12L, gbPeAn(3, 32, octetiPeSigiliu, blocuriPeAn))).isGreaterThan(700L);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static long gbPeAn(
|
private static long gbPeAn(
|
||||||
final int sigilii, final int interval, final long octetiPeSigiliu, final long blocuriPeAn) {
|
final int seals, final int interval, final long bytesPerSeal, final long blocuriPeAn) {
|
||||||
return (long) sigilii * octetiPeSigiliu * blocuriPeAn / interval / (1024L * 1024L * 1024L);
|
return (long) seals * bytesPerSeal * blocuriPeAn / interval / (1024L * 1024L * 1024L);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static long zile(final long gbLiberi, final long gbPeAn) {
|
private static long zile(final long gbLiberi, final long gbPeAn) {
|
||||||
|
|||||||
@ -0,0 +1,177 @@
|
|||||||
|
/* 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,206 @@
|
|||||||
|
/* 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,225 @@
|
|||||||
|
/* 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
|
* 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
|
* the License. You may obtain a copy of the License at
|
||||||
@ -155,9 +155,8 @@ public class PqAnchorConfigTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void emergencyCeilingLowersTheThresholdAndCanNeverRaiseIt() {
|
public void emergencyCeilingLowersTheThresholdAndCanNeverRaiseIt() {
|
||||||
// THE EMERGENCY CEILING, the only de-arm that works when the chain is ALREADY STOPPED: a halted
|
// D1, the only de-arm that works when the chain is ALREADY STOPPED: a halted chain cannot
|
||||||
// chain cannot deliver a height-scheduled configuration change, so the control has to be local
|
// deliver a height-scheduled configuration change, so the control has to be local to the node.
|
||||||
// to the node.
|
|
||||||
final PqAnchorConfig lowered =
|
final PqAnchorConfig lowered =
|
||||||
new PqAnchorConfig(2800L, 1000L, schedule(), OptionalInt.of(1), false);
|
new PqAnchorConfig(2800L, 1000L, schedule(), OptionalInt.of(1), false);
|
||||||
assertThat(lowered.minSealsAt(4000L)).isEqualTo(1);
|
assertThat(lowered.minSealsAt(4000L)).isEqualTo(1);
|
||||||
@ -657,7 +656,13 @@ public class PqAnchorConfigTest {
|
|||||||
* POSITIVE CONTROL for the refusal message itself. The message is the whole product here: an
|
* 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,
|
* 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
|
* the problem, the repair, and the sequencing rule that keeps a fleet restart from killing the
|
||||||
* chain at quorum 5 of 7.
|
* 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.
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
public void theRefusalMessageCarriesEverythingAnOperatorNeedsAtThreeInTheMorning() {
|
public void theRefusalMessageCarriesEverythingAnOperatorNeedsAtThreeInTheMorning() {
|
||||||
@ -676,7 +681,8 @@ public class PqAnchorConfigTest {
|
|||||||
.hasMessageContaining("a step exactly at " + H)
|
.hasMessageContaining("a step exactly at " + H)
|
||||||
.hasMessageContaining("FIX correct BESU_OPTS on THIS node")
|
.hasMessageContaining("FIX correct BESU_OPTS on THIS node")
|
||||||
.hasMessageContaining("restart one at a time")
|
.hasMessageContaining("restart one at a time")
|
||||||
.hasMessageContaining("At quorum 5 of 7 you lose the chain")
|
.hasMessageContaining("never in parallel")
|
||||||
|
.hasMessageContaining("more than f")
|
||||||
.hasMessageContaining("EMERGENCY " + PqAnchorConfig.PROPERTY_DISABLE + "=true");
|
.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
|
* 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
|
* the License. You may obtain a copy of the License at
|
||||||
|
|||||||
@ -2,10 +2,8 @@
|
|||||||
* AERE, 2026-08-07. The anchor interval: a certificate on every Nth block instead of every block.
|
* 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
|
* 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 multiplies the header by almost five even with the cap at
|
* Ethereum. A certificate in EVERY block costs 120.5 GB per year per node even with the cap at K=3.
|
||||||
* K=3, and on a chain with empty blocks the headers are close to everything that gets written to
|
* The fleet's disks are 38 and 75 GB, so the design does not fit anywhere.
|
||||||
* 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:
|
* 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
|
* block N+1 commits to the hash of N. So an anchor at height A, whose vanityData binds a Falcon
|
||||||
@ -132,10 +130,10 @@ class PqAnchorIntervalTest {
|
|||||||
// ---------------------------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------------------------
|
||||||
@Test
|
@Test
|
||||||
void aNodeWithNoAnchorAtAllHasNoAnchorHeights() {
|
void aNodeWithNoAnchorAtAllHasNoAnchorHeights() {
|
||||||
final PqAnchorConfig niciodata = PqAnchorConfig.never(2800L).withAnchorInterval(OptionalInt.of(100));
|
final PqAnchorConfig never = PqAnchorConfig.never(2800L).withAnchorInterval(OptionalInt.of(100));
|
||||||
for (final long n : new long[] {0L, 1L, H, H + 100, Long.MAX_VALUE - 1}) {
|
for (final long n : new long[] {0L, 1L, H, H + 100, Long.MAX_VALUE - 1}) {
|
||||||
assertThat(niciodata.isAnchorHeight(n)).as("height %d", n).isFalse();
|
assertThat(never.isAnchorHeight(n)).as("height %d", n).isFalse();
|
||||||
assertThat(niciodata.anchorAppliesAt(n)).as("height %d", n).isFalse();
|
assertThat(never.anchorAppliesAt(n)).as("height %d", n).isFalse();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,16 +1,15 @@
|
|||||||
/*
|
/*
|
||||||
* AERE, 2026-08-07. THE THRESHOLD FLOOR: a schedule whose effective K is zero everywhere leaves the
|
* AERE D-147, 2026-08-07. THE THRESHOLD FLOOR: a schedule whose effective K is zero everywhere
|
||||||
* anchor armed and completely toothless, forever, and every tool reports GREEN the whole time it is
|
* leaves the anchor armed and completely toothless, forever, and every tool reports GREEN the whole
|
||||||
* happening, because they all measure what was ASKED FOR and the request is valid.
|
* 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
|
* 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
|
* 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.
|
* that is PRESENT and of the form "<H>:0" reaches the same state, and it used to pass.
|
||||||
*
|
*
|
||||||
* And it is not theoretical: the recommended activation schedule has the form "<H>:0,<H+165000>:3",
|
* And it is not theoretical: PLAN-ACTIVARE recommends "<H>:0,<H+165000>:3", which STARTS at zero.
|
||||||
* that is, it STARTS at zero, precisely in order to leave a warm-up window. If the second half is
|
* If the second half is lost to a stray quote or a truncated variable, what remains is exactly the
|
||||||
* lost to a stray quote or a truncated variable, what remains is exactly the dangerous form, and
|
* dangerous form.
|
||||||
* that is why the floor looks at the WHOLE schedule.
|
|
||||||
*/
|
*/
|
||||||
package org.hyperledger.besu.consensus.common.bft;
|
package org.hyperledger.besu.consensus.common.bft;
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,99 @@
|
|||||||
|
/*
|
||||||
|
* 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,9 +4,8 @@
|
|||||||
* WHY THIS EXISTS. On 7 August `aere.pq.anchor.maxSeals` and `aere.pq.anchorInterval` were built,
|
* 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
|
* 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
|
* 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 we had just seen, at
|
* anchor, stayed an ASSERTION: there was no producer harness in the tree, and D-148 had just shown
|
||||||
* the wiring of the rules into the validation chain, what a piece of code that no proof touches
|
* what a piece of code that no proof touches costs.
|
||||||
* costs.
|
|
||||||
*
|
*
|
||||||
* This class touches it. It counts the seals written, it does not assume them.
|
* 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:
|
* 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
|
* 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 about two
|
* that is eligible, not as many as the threshold demands. At 666 bytes a seal, that means 200.9 GB
|
||||||
* thirds more header written than the threshold asks for, and the surplus buys nothing: what a
|
* per node per year instead of 120.5, and the surplus buys nothing: what a verifier demands is THE
|
||||||
* verifier demands is THE THRESHOLD.
|
* THRESHOLD.
|
||||||
*
|
*
|
||||||
* WHAT THIS FILE GUARDS, and this is the dangerous part: a cap set BELOW the highest K in the
|
* 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
|
* 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
|
* 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
|
* 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
|
* 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
|
* the License. You may obtain a copy of the License at
|
||||||
@ -27,9 +27,10 @@ import org.junit.jupiter.api.Test;
|
|||||||
* AERE GARDA-PRAG: the PLANTED FAILURE for the seal-threshold guard.
|
* 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
|
* <p>Each refusal test builds, by hand, the exact configuration that stops the chain, and asserts
|
||||||
* that the guard sees it. The bound asserted is {@code K <= quorum(N) - 1}, which is 4 at N=7 and 2
|
* that the guard sees it. REVISED 2026-08-20: the bound asserted is {@code K <= N - f} (availability
|
||||||
* at N=4, and {@link #growingTheValidatorSetDoesNotBuyQuorumMargin()} is the test that would go green
|
* under the fault budget), which is 5 at N=7 and 3 at N=4. Until D-227 the bound was
|
||||||
* under the WRONG bound {@code K > N - f} and red under the right one.
|
* {@code quorum - 1}, and {@link #atNineValidatorsTheQuorumIsReachableAndAboveNMinusFIsNot()}
|
||||||
|
* carries the dated history of that reversal, with the measurement that forced it.
|
||||||
*
|
*
|
||||||
* <p>The negative control for this file does not live in it: it is a second build of the same tree in
|
* <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
|
* which the guard body is replaced by a stub that accepts everything. Every refusal assertion below
|
||||||
@ -49,27 +50,30 @@ class PqAnchorThresholdGuardTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void theArithmeticIsTheOneTheChainActuallyUses() {
|
void theArithmeticIsTheOneTheChainActuallyUses() {
|
||||||
// The bound is not a constant typed into this test: it is Besu's own quorum formula, minus one.
|
// 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.
|
||||||
assertThat(BftHelpers.calculateRequiredValidatorQuorum(N_LIVE)).isEqualTo(5);
|
assertThat(BftHelpers.calculateRequiredValidatorQuorum(N_LIVE)).isEqualTo(5);
|
||||||
assertThat(PqAnchorThresholdGuard.maxConfigurableThreshold(N_LIVE)).isEqualTo(4);
|
assertThat(PqAnchorThresholdGuard.maxConfigurableThreshold(N_LIVE)).isEqualTo(5);
|
||||||
assertThat(PqAnchorThresholdGuard.byzantineBudget(N_LIVE)).isEqualTo(2);
|
assertThat(PqAnchorThresholdGuard.byzantineBudget(N_LIVE)).isEqualTo(2);
|
||||||
|
|
||||||
assertThat(BftHelpers.calculateRequiredValidatorQuorum(4)).isEqualTo(3);
|
assertThat(BftHelpers.calculateRequiredValidatorQuorum(4)).isEqualTo(3);
|
||||||
assertThat(PqAnchorThresholdGuard.maxConfigurableThreshold(4)).isEqualTo(2);
|
assertThat(PqAnchorThresholdGuard.maxConfigurableThreshold(4)).isEqualTo(3);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void plantedFailureAThresholdEqualToTheQuorumIsRefused() {
|
void plantedFailureAThresholdAboveNMinusFIsRefused() {
|
||||||
|
// At N=7, N - f = 5, so 6 is the first fatal rung: with f=2 validators down only 5 seals exist.
|
||||||
assertThatThrownBy(
|
assertThatThrownBy(
|
||||||
() ->
|
() ->
|
||||||
PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort(
|
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, 6), OptionalInt.empty()),
|
||||||
N_LIVE))
|
N_LIVE))
|
||||||
.isInstanceOf(FalconSealSupport.ActivationConfigException.class)
|
.isInstanceOf(FalconSealSupport.ActivationConfigException.class)
|
||||||
.hasMessageContaining(PqAnchorThresholdGuard.CODE)
|
.hasMessageContaining(PqAnchorThresholdGuard.CODE)
|
||||||
.hasMessageContaining("REFUSING TO START")
|
.hasMessageContaining("REFUSING TO START")
|
||||||
.hasMessageContaining("reaches 5 at height " + (H + 21_600L))
|
.hasMessageContaining("reaches 6 at height " + (H + 21_600L))
|
||||||
.hasMessageContaining("may be configured at this set size is 4");
|
.hasMessageContaining("may be configured at this set size is 5");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@ -84,14 +88,14 @@ class PqAnchorThresholdGuardTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void plantedFailureTheVeryFirstStepMayAlsoBeFatal() {
|
void plantedFailureTheVeryFirstStepMayAlsoBeFatal() {
|
||||||
// A schedule that opens AT the quorum. The producer's existing log-only warning covers K>0 at H
|
// A schedule that opens ABOVE N - f. 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.
|
// for a different reason; this asserts the refusal fires on the same step.
|
||||||
assertThatThrownBy(
|
assertThatThrownBy(
|
||||||
() ->
|
() ->
|
||||||
PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort(
|
PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort(
|
||||||
armed(Map.of(H, 5), OptionalInt.empty()), N_LIVE))
|
armed(Map.of(H, 6), OptionalInt.empty()), N_LIVE))
|
||||||
.isInstanceOf(FalconSealSupport.ActivationConfigException.class)
|
.isInstanceOf(FalconSealSupport.ActivationConfigException.class)
|
||||||
.hasMessageContaining("reaches 5 at height " + H);
|
.hasMessageContaining("reaches 6 at height " + H);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@ -99,7 +103,7 @@ class PqAnchorThresholdGuardTest {
|
|||||||
assertThatCode(
|
assertThatCode(
|
||||||
() ->
|
() ->
|
||||||
PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort(
|
PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort(
|
||||||
armed(Map.of(H, 0, H + 7_200L, 1, H + 21_600L, 4), OptionalInt.empty()),
|
armed(Map.of(H, 0, H + 7_200L, 1, H + 21_600L, 5), OptionalInt.empty()),
|
||||||
N_LIVE))
|
N_LIVE))
|
||||||
.doesNotThrowAnyException();
|
.doesNotThrowAnyException();
|
||||||
}
|
}
|
||||||
@ -115,23 +119,33 @@ class PqAnchorThresholdGuardTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void growingTheValidatorSetDoesNotBuyQuorumMargin() {
|
void atNineValidatorsTheQuorumIsReachableAndAboveNMinusFIsNot() {
|
||||||
// THIS is the test that separates the right bound from the wrong one. At N=9 the quorum is 6
|
// HISTORY, kept on purpose: until 2026-08-20 this test was named
|
||||||
// while N-f is 7, so the rule "refuse when K > N - f" would ACCEPT K=7, which is a rung no
|
// growingTheValidatorSetDoesNotBuyQuorumMargin and asserted that K=6 and K=7 are both refused
|
||||||
// proposer can ever reach. Both 6 and 7 must be 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.
|
||||||
assertThat(BftHelpers.calculateRequiredValidatorQuorum(9)).isEqualTo(6);
|
assertThat(BftHelpers.calculateRequiredValidatorQuorum(9)).isEqualTo(6);
|
||||||
assertThat(9 - PqAnchorThresholdGuard.byzantineBudget(9)).isEqualTo(7);
|
assertThat(9 - PqAnchorThresholdGuard.byzantineBudget(9)).isEqualTo(7);
|
||||||
|
assertThat(PqAnchorThresholdGuard.maxConfigurableThreshold(9)).isEqualTo(7);
|
||||||
|
|
||||||
assertThatThrownBy(
|
assertThatCode(
|
||||||
() ->
|
() ->
|
||||||
PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort(
|
PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort(
|
||||||
armed(Map.of(H, 0, H + 100L, 6), OptionalInt.empty()), 9))
|
armed(Map.of(H, 0, H + 100L, 6), OptionalInt.empty()), 9))
|
||||||
.isInstanceOf(FalconSealSupport.ActivationConfigException.class);
|
.doesNotThrowAnyException();
|
||||||
|
|
||||||
|
assertThatCode(
|
||||||
|
() ->
|
||||||
|
PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort(
|
||||||
|
armed(Map.of(H, 0, H + 100L, 7), OptionalInt.empty()), 9))
|
||||||
|
.doesNotThrowAnyException();
|
||||||
|
|
||||||
assertThatThrownBy(
|
assertThatThrownBy(
|
||||||
() ->
|
() ->
|
||||||
PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort(
|
PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort(
|
||||||
armed(Map.of(H, 0, H + 100L, 7), OptionalInt.empty()), 9))
|
armed(Map.of(H, 0, H + 100L, 8), OptionalInt.empty()), 9))
|
||||||
.isInstanceOf(FalconSealSupport.ActivationConfigException.class);
|
.isInstanceOf(FalconSealSupport.ActivationConfigException.class);
|
||||||
|
|
||||||
assertThatCode(
|
assertThatCode(
|
||||||
@ -142,18 +156,20 @@ class PqAnchorThresholdGuardTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void theBoundAtFourValidatorsIsTwo() {
|
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.
|
||||||
assertThatThrownBy(
|
assertThatThrownBy(
|
||||||
() ->
|
() ->
|
||||||
PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort(
|
PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort(
|
||||||
armed(Map.of(H, 0, H + 30L, 3), OptionalInt.empty()), 4))
|
armed(Map.of(H, 0, H + 30L, 4), OptionalInt.empty()), 4))
|
||||||
.isInstanceOf(FalconSealSupport.ActivationConfigException.class)
|
.isInstanceOf(FalconSealSupport.ActivationConfigException.class)
|
||||||
.hasMessageContaining("quorum for the 4 validators");
|
.hasMessageContaining("quorum for the 4 validators");
|
||||||
|
|
||||||
assertThatCode(
|
assertThatCode(
|
||||||
() ->
|
() ->
|
||||||
PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort(
|
PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort(
|
||||||
armed(Map.of(H, 0, H + 30L, 2), OptionalInt.empty()), 4))
|
armed(Map.of(H, 0, H + 30L, 3), OptionalInt.empty()), 4))
|
||||||
.doesNotThrowAnyException();
|
.doesNotThrowAnyException();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -163,17 +179,18 @@ class PqAnchorThresholdGuardTest {
|
|||||||
assertThatCode(
|
assertThatCode(
|
||||||
() ->
|
() ->
|
||||||
PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort(
|
PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort(
|
||||||
armed(Map.of(H, 0, H + 21_600L, 5), OptionalInt.of(1)), N_LIVE))
|
armed(Map.of(H, 0, H + 21_600L, 6), OptionalInt.of(1)), N_LIVE))
|
||||||
.doesNotThrowAnyException();
|
.doesNotThrowAnyException();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void aCeilingAboveTheScheduleRescuesNothing() {
|
void aCeilingAboveTheScheduleRescuesNothing() {
|
||||||
// The ceiling can only ever lower. A ceiling of 9 over a fatal 5 leaves the 5 in force.
|
// 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.)
|
||||||
assertThatThrownBy(
|
assertThatThrownBy(
|
||||||
() ->
|
() ->
|
||||||
PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort(
|
PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort(
|
||||||
armed(Map.of(H, 0, H + 21_600L, 5), OptionalInt.of(9)), N_LIVE))
|
armed(Map.of(H, 0, H + 21_600L, 6), OptionalInt.of(9)), N_LIVE))
|
||||||
.isInstanceOf(FalconSealSupport.ActivationConfigException.class);
|
.isInstanceOf(FalconSealSupport.ActivationConfigException.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,192 @@
|
|||||||
|
/* 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
|
* 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
|
* 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;
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* REGISTRY BINDING AT ARM TIME, THE LINE THAT WAS MISSING. {@code
|
* D-146, THE LINE THAT WAS MISSING. {@code PqRegistryHash.requireBindingsOrThrow} was delivered on
|
||||||
* PqRegistryHash.requireBindingsOrThrow} was delivered with its own tests, and nothing called it.
|
* 2026-08-06 with its own tests, and nothing called it. Its own javadoc said so: "NOT WIRED YET ...
|
||||||
* Its own javadoc said so: "NOT WIRED YET ... the call belongs beside AERE-PQC-REG-ARM-01 in
|
* the call belongs beside AERE-PQC-REG-ARM-01 in FalconSealSupport, which is being edited by another
|
||||||
* FalconSealSupport". This class measures the wire.
|
* 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
|
* <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
|
* 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")
|
+ "trigger is ARMING and not the file")
|
||||||
.doesNotThrowAnyException();
|
.doesNotThrowAnyException();
|
||||||
assertThat(FalconSealSupport.instance().registrySize())
|
assertThat(FalconSealSupport.instance().registrySize())
|
||||||
.describedAs("and an unarmed node's registry is loaded exactly as it was before this guard")
|
.describedAs("and an unarmed node's registry is loaded exactly as it was before D-146")
|
||||||
.isEqualTo(N);
|
.isEqualTo(N);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -227,8 +227,7 @@ public class PqArmingGateTest {
|
|||||||
* An ARMED node with no registry file at all is deliberately NOT this guard's business, and this
|
* 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.
|
* test is what stops that from being a silent decision.
|
||||||
*
|
*
|
||||||
* <p>This guard is about mis-ATTRIBUTION, which needs rows; an empty registry credits nobody. The
|
* <p>D-146 is mis-ATTRIBUTION, which needs rows; an empty registry credits nobody. The condition
|
||||||
* condition
|
|
||||||
* is owned by AERE-PQC-CFG-UNSAFE-08 when the threshold is positive, and MEASURED here: with a
|
* 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.
|
* 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
|
* An earlier revision of this guard refused here, and the cost was exactly that - the intended
|
||||||
@ -243,7 +242,7 @@ public class PqArmingGateTest {
|
|||||||
System.setProperty("aere.falcon.testnetAllowSmallFleet", "true");
|
System.setProperty("aere.falcon.testnetAllowSmallFleet", "true");
|
||||||
|
|
||||||
assertThatCode(FalconSealSupport::instance)
|
assertThatCode(FalconSealSupport::instance)
|
||||||
.describedAs("K=0 over an empty registry is the warm-up regime, not a mis-attribution defect")
|
.describedAs("K=0 over an empty registry is the warm-up regime, not a D-146 defect")
|
||||||
.doesNotThrowAnyException();
|
.doesNotThrowAnyException();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -253,7 +252,7 @@ public class PqArmingGateTest {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* A row that carries a Falcon possession proof and no ECDSA claim proves that SOMEBODY holds the
|
* 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 this guard.
|
* 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
|
* <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
|
* round. The expectation written first was AERE-PQC-REG-ARM-02. What actually happens is a refusal
|
||||||
@ -291,8 +290,8 @@ public class PqArmingGateTest {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Arm through the CERTIFICATE ANCHOR only, leaving {@code aere.falcon.forkBlock} unset. The
|
* 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
|
* threshold is 2, which {@code worstCaseKeyedSigners(4, 4)} = 3 guarantees, so the D-078 guard
|
||||||
* threshold-reachability guard next door stays silent and cannot be mistaken for this one.
|
* next door stays silent and cannot be mistaken for this one.
|
||||||
*/
|
*/
|
||||||
private void armWithAnchorOnly() {
|
private void armWithAnchorOnly() {
|
||||||
System.setProperty(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, Long.toString(FORK));
|
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
|
* 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
|
* 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;
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ROTATION HARDENING (b-v2). The repair of the repair: the caller's MOTIVE decides, not the height.
|
* 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
|
* <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
|
* 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
|
* 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.
|
* the guard exactly those numbers too. No arithmetic on the height separates them.
|
||||||
*
|
*
|
||||||
* <p>THE OPERATIONAL CONSEQUENCE, in the words of that failure itself: {@code refusing to
|
* <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
|
* 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
|
* 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.
|
* 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
|
* <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
|
* 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
|
* answer (which is the pre-2026-08-06 defect restored). The two plants are run in opposite
|
||||||
* directions, and each was measured before this class was allowed to count as evidence.
|
* directions and both are recorded in the evidence directory.
|
||||||
*/
|
*/
|
||||||
public class PqCallerIntentTest {
|
public class PqCallerIntentTest {
|
||||||
|
|
||||||
@ -100,10 +100,10 @@ public class PqCallerIntentTest {
|
|||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
public void setUp() throws Exception {
|
public void setUp() throws Exception {
|
||||||
// AERE REGISTRY BINDING (2026-08-06): a v2, PROOF-BOUND registry. It used to be v1, with
|
// AERE D-146 (2026-08-06): a v2, PROOF-BOUND registry. It used to be v1 with addresses spelled
|
||||||
// addresses spelled 0xA00+i, which no secp256k1 key can sign for, so this fixture described a
|
// 0xA00+i, which no secp256k1 key can sign for, so this fixture described a fleet that could
|
||||||
// fleet that could never satisfy AERE-PQC-REG-ARM-02 once that guard was wired. The registry is
|
// never satisfy AERE-PQC-REG-ARM-02 once that guard was wired. The registry is bound at H, the
|
||||||
// bound at H, the height this fixture arms the anchor from.
|
// height this fixture arms the anchor from.
|
||||||
final KeccakDigest kd = new KeccakDigest(256);
|
final KeccakDigest kd = new KeccakDigest(256);
|
||||||
final StringBuilder manifest = new StringBuilder();
|
final StringBuilder manifest = new StringBuilder();
|
||||||
manifest
|
manifest
|
||||||
@ -177,7 +177,7 @@ public class PqCallerIntentTest {
|
|||||||
.describedAs(
|
.describedAs(
|
||||||
"HISTORY door at 1030, armed from 1000, no schedule: a node judging somebody else's "
|
"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 "
|
+ "header cannot say which keys were in force there, so it REFUSES. Answering from "
|
||||||
+ "the head registry here is the rotation defect verbatim")
|
+ "the head registry here is D2/T2 verbatim")
|
||||||
.isFalse();
|
.isFalse();
|
||||||
|
|
||||||
assertThat(pqc.verifyAtOwnHead(OWN_HEAD, 0, MESSAGE, sealByIndexZero))
|
assertThat(pqc.verifyAtOwnHead(OWN_HEAD, 0, MESSAGE, sealByIndexZero))
|
||||||
@ -206,7 +206,7 @@ public class PqCallerIntentTest {
|
|||||||
.describedAs(
|
.describedAs(
|
||||||
"PqSealPersistenceTest restored 0 of 3 genuine seals under the first shape. A node "
|
"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 "
|
+ "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 that restart deadlock")
|
+ "propose, and the file is the documented way out of the D-141 deadlock")
|
||||||
.isTrue();
|
.isTrue();
|
||||||
assertThat(pqc.addressForIndexAtOwnHead(OWN_HEAD, 0))
|
assertThat(pqc.addressForIndexAtOwnHead(OWN_HEAD, 0))
|
||||||
.describedAs("and the index must bind, or every stored seal is dropped as unknown")
|
.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 FalconSealSupport pqc = FalconSealSupport.instance();
|
||||||
final PqRegistryHash.Registry held = PqRegistryHash.loadAuto(genesisPath);
|
final PqRegistryHash.Registry held = PqRegistryHash.loadAuto(genesisPath);
|
||||||
final Map<Long, String> entries = new LinkedHashMap<>();
|
final Map<Long, String> entries = new LinkedHashMap<>();
|
||||||
// AERE REGISTRY BINDING (2026-08-06): hashFor, not hashV1. A schedule entry has to carry the
|
// AERE D-146 (2026-08-06): hashFor, not hashV1. A schedule entry has to carry the canonical
|
||||||
// canonical hash OF THE REGISTRY IT NAMES, and this fixture's registry is now v2, which hashes
|
// hash OF THE REGISTRY IT NAMES, and this fixture's registry is now v2, which hashes under a
|
||||||
// under a different domain tag. MEASURED: leaving hashV1 here made the entry name a registry
|
// different domain tag. MEASURED: leaving hashV1 here made the entry name a registry nobody
|
||||||
// nobody holds, and the height-resolved lookups fell through to a refusal - a green test
|
// holds, and the height-resolved lookups fell through to a refusal - a green test turning red
|
||||||
// turning red for a reason that had nothing to do with what it measures. This is the same
|
// for a reason that had nothing to do with what it measures. This is the same breakage a real
|
||||||
// breakage a real genesis takes: any config.pqRegistryHash computed before the registry was
|
// genesis takes: any config.pqRegistryHash computed before the registry was rebuilt as v2
|
||||||
// rebuilt as v2 stops matching the moment it is rebuilt.
|
// stops matching the moment it is rebuilt.
|
||||||
entries.put(H, PqRegistryHash.hashFor(held, CHAIN_ID));
|
entries.put(H, PqRegistryHash.hashFor(held, CHAIN_ID));
|
||||||
pqc.verifyRegistryBindingOrAbort(0L, CHAIN_ID, scheduleFromGenesis(entries));
|
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
|
* 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
|
* 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;
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* THE FLEET-RESTART DEADLOCK, AND THE STATE MACHINE THE REPAIR MOVES.
|
* D-140. THE FLEET-RESTART DEADLOCK, AND THE STATE MACHINE THE REPAIR MOVES.
|
||||||
*
|
*
|
||||||
* <p>MEASURED FIRST, ON A NETWORK, NOT ASSUMED. A full activation rehearsal on a seven-node test
|
* <p>MEASURED FIRST, ON A NETWORK, NOT ASSUMED. The 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
|
* network (repetitie-activare-2026-08-05) found that with the anchor armed at K>0 a SIMULTANEOUS
|
||||||
* the chain for good. The node said it verbatim: "refusing to
|
* 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
|
* propose ... holds 0 valid eligible Falcon seal(s) ... threshold is 3", over "Attachment stays OFF
|
||||||
* (fail-safe)".
|
* (fail-safe)".
|
||||||
*
|
*
|
||||||
@ -75,8 +75,8 @@ import org.junit.jupiter.api.io.TempDir;
|
|||||||
* no-op rather than a second registry load.
|
* no-op rather than a second registry load.
|
||||||
* <li>{@link #aGenesisAnchoredNodeIsArmedImmediatelyAfterRestart()} - the rehearsal's own
|
* <li>{@link #aGenesisAnchoredNodeIsArmedImmediatelyAfterRestart()} - the rehearsal's own
|
||||||
* stimulus replayed against THIS tree, and it does not fail the way the network did. Read its
|
* stimulus replayed against THIS tree, and it does not fail the way the network did. Read its
|
||||||
* javadoc: the rehearsal binary predates the seal-attachment repair, and the line it logged
|
* javadoc: the rehearsal binary predates D-078, and the line it logged came from a condition
|
||||||
* came from a condition this tree no longer contains.
|
* this tree no longer contains.
|
||||||
* </ol>
|
* </ol>
|
||||||
*
|
*
|
||||||
* <p>NOT MEASURED here, and named so it is not read as covered: that a real Besu process reads slot
|
* <p>NOT MEASURED here, and named so it is not read as covered: that a real Besu process reads slot
|
||||||
@ -84,9 +84,15 @@ import org.junit.jupiter.api.io.TempDir;
|
|||||||
* rehearsal network is the instrument for it), and that seven live nodes recover from a real
|
* 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.
|
* 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 {
|
public class PqFleetRestartArmingTest {
|
||||||
|
|
||||||
/** Fleet size: seven, the validator count this deployment runs. */
|
/**
|
||||||
|
* 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;
|
private static final int N = 7;
|
||||||
|
|
||||||
/** Height at which the anchor contract is expected to be observable. */
|
/** Height at which the anchor contract is expected to be observable. */
|
||||||
@ -110,6 +116,21 @@ public class PqFleetRestartArmingTest {
|
|||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
public void setUp() throws Exception {
|
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();
|
resetFalconSingleton();
|
||||||
|
|
||||||
final SecureRandom rnd = SecureRandomProvider.createSecureRandom();
|
final SecureRandom rnd = SecureRandomProvider.createSecureRandom();
|
||||||
@ -344,9 +365,9 @@ public class PqFleetRestartArmingTest {
|
|||||||
* The seven-node rehearsal ran a GENESIS-anchored registry, and the line it logged after the
|
* 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
|
* 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
|
* COVERAGE cannot be proven. Attachment stays OFF (fail-safe)". That condition does not exist in
|
||||||
* this tree: {@code grep} for it returns nothing, because the seal-attachment repair took the
|
* this tree: {@code grep} for it returns nothing, because D-078 (2026-08-02) removed the fleet
|
||||||
* fleet-wide question out of the per-commit gate. The rehearsal binary was built before that
|
* question from the per-commit gate. The rehearsal binary was built from the 2026-08-01 tree,
|
||||||
* repair landed, and still had it.
|
* which still had it.
|
||||||
*
|
*
|
||||||
* <p>So this test states what is true HERE: a genesis-anchored node, freshly constructed, with no
|
* <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
|
* 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
|
* 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
|
* 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;
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* FORK-HEIGHT ARMING. THE MEASUREMENT THAT DID NOT EXIST.
|
* D-079. THE MEASUREMENT THAT DID NOT EXIST.
|
||||||
*
|
*
|
||||||
* <p>The concern, as it was written down: a malformed forkBlock falls OPEN, with only a log line,
|
* <p>The registry entry reads: "a malformed forkBlock falls OPEN, with only a log line, and arming
|
||||||
* and arming it at or before the anchor observation height passes undetected. It stood as an
|
* it at or before the anchor observation height passes undetected", and it carried {@code verifica:
|
||||||
* assertion with no command behind it for months. This file is the command that can fail.
|
* 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
|
* <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
|
* incident of February 2025 made expensive for everybody: a fork-activation parameter that is wrong
|
||||||
@ -75,6 +75,8 @@ import org.junit.jupiter.api.io.TempDir;
|
|||||||
* the guards that already abort, and nothing in this tree catches {@code
|
* the guards that already abort, and nothing in this tree catches {@code
|
||||||
* FalconSealSupport.ActivationConfigException}.
|
* 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 {
|
public class PqForkArmingTest {
|
||||||
|
|
||||||
/** Fleet size; nine, because the blocking guard refuses to arm below nine. */
|
/** Fleet size; nine, because the blocking guard refuses to arm below nine. */
|
||||||
@ -92,8 +94,8 @@ public class PqForkArmingTest {
|
|||||||
private static final String ANCHOR_ADDRESS = "0x0000000000000000000000000000000000000fa1";
|
private static final String ANCHOR_ADDRESS = "0x0000000000000000000000000000000000000fa1";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* REGISTRY BINDING: the chain this fixture's registries are BOUND to. Every proof commits to it,
|
* AERE D-146: the chain this fixture's registries are BOUND to. Every proof commits to it, so it
|
||||||
* so it has to be stated rather than defaulted.
|
* has to be stated rather than defaulted.
|
||||||
*/
|
*/
|
||||||
private static final long CHAIN_ID = 2_800L;
|
private static final long CHAIN_ID = 2_800L;
|
||||||
|
|
||||||
@ -104,7 +106,7 @@ public class PqForkArmingTest {
|
|||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
public void setUp() throws Exception {
|
public void setUp() throws Exception {
|
||||||
// REGISTRY BINDING: both registries below are v2 and PROOF-BOUND, bound at FORK, the
|
// 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
|
// 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
|
// secp256k1 key can sign for, so this whole fixture became unstartable the moment
|
||||||
// AERE-PQC-REG-ARM-02 was wired into the constructor.
|
// 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
|
* 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
|
* the License. You may obtain a copy of the License at
|
||||||
@ -33,8 +33,7 @@ import org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer;
|
|||||||
import org.junit.jupiter.api.io.TempDir;
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* THRESHOLD REACHABILITY, THE HALF THAT WAS STILL OPEN: is the threshold K one the fleet can be
|
* D-078, THE HALF THAT WAS STILL OPEN: is the threshold K one the fleet can be GUARANTEED to meet?
|
||||||
* GUARANTEED to meet?
|
|
||||||
*
|
*
|
||||||
* <p>The 2026-08-02 repair closed the mechanism that stopped the chain on one add-validator vote: it
|
* <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
|
* took the fleet-wide coverage question out of the per-commit attachment gate and made coverage a
|
||||||
@ -67,8 +66,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
|
* 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
|
* 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
|
* 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 address-binding guard already refused
|
* configuration error can take, and it is the same shape the A8 repair already refused to allow for
|
||||||
* to allow for a non-address-bound manifest.
|
* a non-address-bound manifest.
|
||||||
*
|
*
|
||||||
* <p>WHY AT CONFIG TIME AND NOWHERE ELSE. The lesson is borrowed, not invented: CometBFT applies a
|
* <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
|
* validator-set change only at H+2 and Ethereum's light-client protocol carries {@code
|
||||||
@ -77,13 +76,14 @@ import org.junit.jupiter.api.io.TempDir;
|
|||||||
* mechanism, because at seven nodes under one operator there is no committee to sample. We can copy
|
* 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
|
* 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
|
* 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 the fork-arming
|
* AERE-PQC-CFG-UNSAFE-04 and, for the fork height, AERE-PQC-CFG-UNSAFE-06/07 in D-079.
|
||||||
* configuration guard.
|
|
||||||
*
|
*
|
||||||
* <p>NOT MEASURED here, and named so it is not read as covered: what a LIVE fleet does in the rounds
|
* <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
|
* 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 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 {
|
public class PqForkThresholdReachabilityTest {
|
||||||
|
|
||||||
/** Anchor activation height H. */
|
/** Anchor activation height H. */
|
||||||
@ -96,8 +96,8 @@ public class PqForkThresholdReachabilityTest {
|
|||||||
private static final int K = 5;
|
private static final int K = 5;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* REGISTRY BINDING: the chain the registries this fixture writes are BOUND to. It is the same
|
* AERE D-146: the chain the registries this fixture writes are BOUND to. It is the same value
|
||||||
* value {@link #armAnchor} states in {@code aere.pq.chainId}: a registry bound to one chain and an
|
* {@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.
|
* anchor armed on another is a configuration this fixture must never accidentally describe.
|
||||||
*/
|
*/
|
||||||
private static final long CHAIN_ID = 2_800L;
|
private static final long CHAIN_ID = 2_800L;
|
||||||
@ -198,8 +198,8 @@ public class PqForkThresholdReachabilityTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void withNoAnchorConfiguredTheGuardIsInert() throws Exception {
|
public void withNoAnchorConfiguredTheGuardIsInert() throws Exception {
|
||||||
// With aere.pq.anchorBlock unset there is no anchor, so K does not exist and there is nothing
|
// aere.pq.anchorBlock is UNSET on the live chain, so K does not exist and there is nothing to
|
||||||
// to compare. A guard that could stop a node in that state would be a new way to lose the fleet,
|
// 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.
|
// which is a strictly worse defect than the one it repairs.
|
||||||
writeAnchoredRegistry(7);
|
writeAnchoredRegistry(7);
|
||||||
System.setProperty("aere.falcon.validatorCount", "9");
|
System.setProperty("aere.falcon.validatorCount", "9");
|
||||||
@ -227,9 +227,8 @@ public class PqForkThresholdReachabilityTest {
|
|||||||
@Test
|
@Test
|
||||||
public void aPositiveThresholdWithNoAnchoredKeysMustRefuseToStart() throws Exception {
|
public void aPositiveThresholdWithNoAnchoredKeysMustRefuseToStart() throws Exception {
|
||||||
// No manifest anywhere and K=5: guaranteed is 0, so every block at or above H would be rejected
|
// 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 genesis-binding refusal,
|
// for want of a certificate nobody can produce. Distinct from the A8 refusal, which only fires
|
||||||
// which only fires when aere.falcon.forkBlock is set; the anchor path has its own arming
|
// when aere.falcon.forkBlock is set; the anchor path has its own arming height.
|
||||||
// height.
|
|
||||||
System.setProperty("aere.falcon.validatorCount", "7");
|
System.setProperty("aere.falcon.validatorCount", "7");
|
||||||
armAnchor(K);
|
armAnchor(K);
|
||||||
|
|
||||||
@ -302,7 +301,7 @@ public class PqForkThresholdReachabilityTest {
|
|||||||
* genesis is rather than by a flag.
|
* genesis is rather than by a flag.
|
||||||
*/
|
*/
|
||||||
private void writeAnchoredRegistry(final int count) throws Exception {
|
private void writeAnchoredRegistry(final int count) throws Exception {
|
||||||
// REGISTRY BINDING: v2, proof-bound, bound at H, the height armAnchor() arms from. The
|
// 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
|
// 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.
|
// 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 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
|
* 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
|
* 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;
|
import org.mockito.quality.Strictness;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* VALIDATOR-SET CHANGE UNDER AN ARMED ANCHOR. THE MEASUREMENT THAT DID NOT EXIST.
|
* D-078. THE MEASUREMENT THAT DID NOT EXIST.
|
||||||
*
|
*
|
||||||
* <p>The concern, as it was written down, was this: if PQC were armed, an ordinary add-validator
|
* <p>The registry entry reads: "if PQC were armed, an ordinary add-validator vote would stop the
|
||||||
* vote would stop the chain, because the Falcon blocking quorum follows the dynamic set and cannot
|
* chain: the Falcon blocking quorum follows the dynamic set and cannot be reached inside the vote
|
||||||
* be reached inside the vote window. It was carried as UNMEASURED, on the argument that the direct
|
* window", and it carried {@code verifica: NICIUNA} because "the direct measurement would require
|
||||||
* measurement would require ARMING PQC on a chain, which is exactly the thing that stops the chain.
|
* 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
|
* <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
|
* "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,
|
* 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
|
* 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
|
* three with a REAL address-bound genesis-anchored registry and REAL Falcon-512 keys, and asks the
|
||||||
* question that was held to be unaskable.
|
* question the registry says cannot be asked.
|
||||||
*
|
*
|
||||||
* <p>WHAT EACH TEST MEASURES, and why each of them can fail:
|
* <p>WHAT EACH TEST MEASURES, and why each of them can fail:
|
||||||
*
|
*
|
||||||
* <ol>
|
* <ol>
|
||||||
* <li>{@link #baselineTheGateIsArmedWhileTheRegistryCoversTheSet()} - the negative control for
|
* <li>{@link #baselineTheGateIsArmedWhileTheRegistryCoversTheSet()} - the negative control for
|
||||||
* every other test here. If the gate were simply always off, or the registry never loaded,
|
* 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 the question
|
* the three tests below would "pass" for a reason that has nothing to do with D-078. This one
|
||||||
* under test. This one fails if the fixture is not genuinely armed.
|
* fails if the fixture is not genuinely armed.
|
||||||
* <li>{@link #addingOneValidatorMustNotTurnSealAttachmentOff()} - the concern itself, on the exact
|
* <li>{@link #addingOneValidatorMustNotTurnSealAttachmentOff()} - D-078 itself, on the exact
|
||||||
* stimulus in the title: one more validator in the set, with no Falcon key.
|
* stimulus in the title: one more validator in the set, with no Falcon key.
|
||||||
* <li>{@link #aNodeStartedAboveTheAnchorHeightMustStillAttach()} - the SAME halt through a much
|
* <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
|
* more ordinary door than a vote: a restart. Above the anchor height the only caller of
|
||||||
@ -90,6 +90,8 @@ import org.mockito.quality.Strictness;
|
|||||||
* live fleet takes to stop once every proposer refuses, and what a syncing node does meanwhile.
|
* 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.
|
* 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 {
|
public class PqForkValidatorSetChangeTest {
|
||||||
|
|
||||||
/** Anchor activation height H used throughout. */
|
/** Anchor activation height H used throughout. */
|
||||||
@ -121,7 +123,7 @@ public class PqForkValidatorSetChangeTest {
|
|||||||
// equals the hash stored in the genesis alloc. The digest is accumulated here in lockstep with
|
// 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.
|
// the manifest text, so the fixture is anchored the same way a real genesis is.
|
||||||
//
|
//
|
||||||
// REGISTRY BINDING: v2, proof-bound, bound at H. The addresses come from PqV2Fixture and
|
// 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
|
// 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.
|
// address is on the row and no key produces the 0xA00+i addresses this used to spell.
|
||||||
final KeccakDigest kd = new KeccakDigest(256);
|
final KeccakDigest kd = new KeccakDigest(256);
|
||||||
@ -213,7 +215,7 @@ public class PqForkValidatorSetChangeTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------------------
|
||||||
// 2. The concern on its own stimulus: one validator added.
|
// 2. D-078 on its own stimulus: one validator added.
|
||||||
// -----------------------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------------------
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@ -231,9 +233,10 @@ public class PqForkValidatorSetChangeTest {
|
|||||||
|
|
||||||
assertThat(pqc.attachmentArmed(H + 3L))
|
assertThat(pqc.attachmentArmed(H + 3L))
|
||||||
.describedAs(
|
.describedAs(
|
||||||
"one ordinary add-validator vote must not switch Falcon seal ATTACHMENT off. It is a "
|
"D-078: one ordinary add-validator vote must not switch Falcon seal ATTACHMENT off. "
|
||||||
+ "fleet-wide fact, so it turns off on EVERY node at the same height, and with no "
|
+ "It is a fleet-wide fact, so it turns off on EVERY node at the same height; with "
|
||||||
+ "node attaching no proposer can gather the K=%d seals an anchored header needs.",
|
+ "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)
|
K)
|
||||||
.isTrue();
|
.isTrue();
|
||||||
assertThat(pqc.sign(H + 3L, message(H + 2L)))
|
assertThat(pqc.sign(H + 3L, message(H + 2L)))
|
||||||
@ -350,7 +353,7 @@ public class PqForkValidatorSetChangeTest {
|
|||||||
* Every other test here asserts that the gate says YES. Replace {@code attachmentArmed} with
|
* 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
|
* {@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
|
* fail. These four assertions are what makes that substitution impossible: each names a condition
|
||||||
* the attachment repair deliberately did NOT touch.
|
* the D-078 repair deliberately did NOT touch.
|
||||||
*
|
*
|
||||||
* @throws Exception if the fixture cannot be rebuilt
|
* @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
|
* 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
|
* 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.
|
* THE COMPATIBILITY PROPERTY, which is the one that decides whether any of this can be shipped.
|
||||||
*
|
*
|
||||||
* <p>The three anchor patches plus the registry-binding arming gate are meant to travel onto the
|
* <p>The three anchor patches plus the D-146 arming gate are meant to travel onto the seven live
|
||||||
* seven live boxes BEFORE the activation height, so that the fleet is already running the binary
|
* boxes BEFORE the activation height, so that the fleet is already running the binary when the
|
||||||
* when the height arrives and activation is a restart-free event. That plan is only sound if a node
|
* height arrives and activation is a restart-free event. That plan is only sound if a node holding
|
||||||
* holding this binary and NO {@code aere.pq.*} configuration is indistinguishable from one holding
|
* this binary and NO {@code aere.pq.*} configuration is indistinguishable from one holding the
|
||||||
* the binary it replaces: it must start, it must produce blocks, and it must not say a word about
|
* binary it replaces: it must start, it must produce blocks, and it must not say a word about an
|
||||||
* an anchor that is not armed. If that property is lost, the whole package is unusable regardless
|
* anchor that is not armed. If that property is lost, the whole package is unusable regardless of
|
||||||
* of how correct the anchor logic is, because it could not be staged.
|
* 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
|
* <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
|
* 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;
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SCHEDULE BOUNDARY (2026-08-15): the PARENT-HEIGHT question at the first anchor.
|
* D-228 (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
|
* <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
|
* 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");
|
genesisPath = tmp.resolve("genesis-d228.json");
|
||||||
Files.writeString(genesisPath, manifest.toString());
|
Files.writeString(genesisPath, manifest.toString());
|
||||||
// DELIBERATELY NOT setting aere.falcon.genesis: the head registry stays EMPTY, which is the
|
// DELIBERATELY NOT setting aere.falcon.genesis: the head registry stays EMPTY, which is the
|
||||||
// public-node shape the defect was measured on.
|
// public-node shape D-228 was measured on.
|
||||||
|
|
||||||
// A genuine Falcon-512 signature by index 0 over MESSAGE.
|
// A genuine Falcon-512 signature by index 0 over MESSAGE.
|
||||||
final FalconSigner signer = new FalconSigner();
|
final FalconSigner signer = new FalconSigner();
|
||||||
@ -168,7 +168,7 @@ public class PqParentHeightAlignmentTest {
|
|||||||
final FalconSealSupport pqc = FalconSealSupport.instance();
|
final FalconSealSupport pqc = FalconSealSupport.instance();
|
||||||
assertThat(pqc.addressForIndexAtHistoric(H - 1L, 0))
|
assertThat(pqc.addressForIndexAtHistoric(H - 1L, 0))
|
||||||
.describedAs(
|
.describedAs(
|
||||||
"SCHEDULE BOUNDARY: PqAnchorSealsRule asks at the PARENT height H-1 about the certificate carried "
|
"D-228: 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 "
|
+ "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")
|
+ "H, and it is VERIFIED; refusing here parks a syncing node at H-1 forever")
|
||||||
.isEqualTo(validators.get(0));
|
.isEqualTo(validators.get(0));
|
||||||
|
|||||||
@ -47,8 +47,8 @@ import org.junit.jupiter.api.Test;
|
|||||||
import org.junit.jupiter.api.io.TempDir;
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AERE REGISTRY BINDING. The registry says validator i has Falcon key k. Nothing said validator i
|
* AERE D-146. The registry says validator i has Falcon key k. Nothing said validator i ever agreed
|
||||||
* ever agreed to that, or that anybody holds k's secret.
|
* 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
|
* <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:
|
* 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
|
* <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
|
* never written anywhere but a JUnit temporary directory. Nothing in this file touches the key
|
||||||
* ceremony or the controls that stand in front of real key generation.
|
* ceremony, the vault, or the three locks that stand in front of real key generation.
|
||||||
*/
|
*/
|
||||||
class PqRegistryBindingTest {
|
class PqRegistryBindingTest {
|
||||||
|
|
||||||
@ -172,7 +172,7 @@ class PqRegistryBindingTest {
|
|||||||
// ceremony: the registry writer, who has every FALCON secret. It signs a perfectly valid
|
// 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
|
// 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
|
// 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 the attribution gap.
|
// proof-of-possession alone does not repair D-146.
|
||||||
final List<Row> rows = rows();
|
final List<Row> rows = rows();
|
||||||
rows.get(0).address = holders.get(1).address();
|
rows.get(0).address = holders.get(1).address();
|
||||||
rows.get(1).address = holders.get(3).address(); // keep addresses distinct
|
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
|
* 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
|
* 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
|
* 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 the attribution
|
* one belonging to the address it filed the key under. That gap is the whole of D-146 and it is
|
||||||
* problem this class exists for, and it is why the Falcon half alone repairs nothing.
|
* why the Falcon half alone repairs nothing.
|
||||||
*/
|
*/
|
||||||
private static void resignAsRegistryWriter(final List<Row> rows) {
|
private static void resignAsRegistryWriter(final List<Row> rows) {
|
||||||
for (int i = 0; i < rows.size(); i++) {
|
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
|
* 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
|
* the License. You may obtain a copy of the License at
|
||||||
@ -38,17 +38,16 @@ import org.junit.jupiter.api.Test;
|
|||||||
import org.junit.jupiter.api.io.TempDir;
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* KEY ROTATION AND REBINDING, the adversarial review of 2026-08-02, at the layer that actually
|
* D2, the adversarial review of 2026-08-02, at the layer that actually answers the question.
|
||||||
* answers the question.
|
|
||||||
*
|
*
|
||||||
* <p>WHAT THE REVIEW MEASURED. {@code PqSignerRegistry} had {@code addressForIndex(int)} and {@code
|
* <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
|
* 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
|
* 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.
|
* key was rotated - same header, same parent, same validator set.
|
||||||
*
|
*
|
||||||
* <p>WHAT WAS REPAIRED BEFORE THIS FILE, AND WHAT WAS NOT. The height-indexed registry change gave
|
* <p>WHAT WAS REPAIRED BEFORE THIS FILE, AND WHAT WAS NOT. Commit f3ebe90c (D-081) gave the
|
||||||
* the validation path {@code addressForIndexAt} / {@code verifyAt} and a height-indexed schedule.
|
* validation path {@code addressForIndexAt} / {@code verifyAt} and a height-indexed schedule. The
|
||||||
* The measurement of 2026-08-05 found the repair INERT, for a reason that is one line long: with no
|
* 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 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
|
* {@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.
|
* signatures, head-registry answers. T2 stood exactly as measured.
|
||||||
@ -95,8 +94,8 @@ public class PqRegistryHeightRefusalTest {
|
|||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
public void setUp() throws Exception {
|
public void setUp() throws Exception {
|
||||||
// AERE REGISTRY BINDING (2026-08-06): v2, proof-bound, bound at H. See PqV2Fixture for why the
|
// AERE D-146 (2026-08-06): v2, proof-bound, bound at H. See PqV2Fixture for why the addresses
|
||||||
// addresses are derived from real secp256k1 keys and can no longer be spelled 0xA00+i.
|
// are derived from real secp256k1 keys and can no longer be spelled 0xA00+i.
|
||||||
final KeccakDigest kd = new KeccakDigest(256);
|
final KeccakDigest kd = new KeccakDigest(256);
|
||||||
final StringBuilder manifest = new StringBuilder();
|
final StringBuilder manifest = new StringBuilder();
|
||||||
manifest
|
manifest
|
||||||
@ -169,11 +168,10 @@ public class PqRegistryHeightRefusalTest {
|
|||||||
|
|
||||||
// No schedule was ever loaded: verifyRegistryBindingOrAbort has not run, which is the state of
|
// 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
|
// every node on chain 2800 today, because config.pqRegistryHash is in no genesis this fleet
|
||||||
// runs (measured 2026-08-05: a search across every deployment and monitoring configuration we
|
// runs (measured 2026-08-05, grep over deploy/ and monitoring/ returns nothing).
|
||||||
// hold returns nothing).
|
|
||||||
assertThat(pqc.verifyAtHistoric(H, 0, MESSAGE, sealByIndexZero))
|
assertThat(pqc.verifyAtHistoric(H, 0, MESSAGE, sealByIndexZero))
|
||||||
.describedAs(
|
.describedAs(
|
||||||
"T2: at the arming height itself, a node with no height-to-registry binding must "
|
"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, "
|
+ "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 "
|
+ "so one key rotation made every block above H unverifiable while the node "
|
||||||
+ "reported success")
|
+ "reported success")
|
||||||
@ -237,9 +235,9 @@ public class PqRegistryHeightRefusalTest {
|
|||||||
// The first scheduled entry sits EXACTLY at the arming height, which is the rule the epoch-list
|
// 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
|
// design states: below H requiredHashAt is empty and the fallback is unreachable by anything
|
||||||
// that decides a header.
|
// that decides a header.
|
||||||
// AERE REGISTRY BINDING (2026-08-06): the hash above is hashFor, not hashV1, because this
|
// AERE D-146 (2026-08-06): the hash above is hashFor, not hashV1, because this fixture's
|
||||||
// fixture's registry is now v2 and hashes under a different domain tag. A schedule entry that
|
// registry is now v2 and hashes under a different domain tag. A schedule entry that names the
|
||||||
// names the v1 number names a registry this node does not hold.
|
// v1 number names a registry this node does not hold.
|
||||||
final PqRegistryHash.Schedule schedule = scheduleFromGenesis(Map.of(H, hash));
|
final PqRegistryHash.Schedule schedule = scheduleFromGenesis(Map.of(H, hash));
|
||||||
pqc.verifyRegistryBindingOrAbort(0L, CHAIN_ID, schedule);
|
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
|
* 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
|
* the License. You may obtain a copy of the License at
|
||||||
@ -30,13 +30,12 @@ import org.junit.jupiter.api.Test;
|
|||||||
import org.junit.jupiter.api.io.TempDir;
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SIGNER-REGISTRY ROTATION AND REVOCATION: the Falcon signer registry has no usable rotation and no
|
* D-081: the Falcon signer registry has no usable rotation and no usable revocation.
|
||||||
* usable revocation.
|
|
||||||
*
|
*
|
||||||
* <p>WHAT IS MEASURED HERE, and why it is measured against the real classes rather than described.
|
* <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 format is documented
|
* {@code config.pqRegistryHash} is a SCHEDULE of {block, hash} entries, and the A8 dossier states
|
||||||
* as one where "a later entry expresses a key rotation". This file asks whether that sentence
|
* that "a later entry expresses a key rotation". This file asks whether that sentence survives
|
||||||
* survives contact with the code that enforces it.
|
* contact with the code that enforces it.
|
||||||
*
|
*
|
||||||
* <p>The enforcement side is {@link PqRegistryHash#matchesAt} and, on the block path, {@code
|
* <p>The enforcement side is {@link PqRegistryHash#matchesAt} and, on the block path, {@code
|
||||||
* FalconSealSupport.registryBindingSatisfiedAt(height)}, which calls it. Both take exactly ONE
|
* FalconSealSupport.registryBindingSatisfiedAt(height)}, which calls it. Both take exactly ONE
|
||||||
@ -45,10 +44,12 @@ import org.junit.jupiter.api.io.TempDir;
|
|||||||
* H2 there are two intervals with two different required hashes, and one file can satisfy at most
|
* H2 there are two intervals with two different required hashes, and one file can satisfy at most
|
||||||
* one of them.
|
* one of them.
|
||||||
*
|
*
|
||||||
* <p>The consequence is not cosmetic and it is not confined to the rotation moment. The binding is
|
* <p>The consequence is not cosmetic and it is not confined to the rotation moment. {@code
|
||||||
* enforced while history is being acquired, not only at the head, so the whole range of heights has
|
* PqRegistryBindingRule} is a DETACHED rule, so it runs on the header-download path, and {@code
|
||||||
* to be satisfiable at once and not merely the current interval. That is the constraint the two
|
* PqAnchorSyncModeGuard} refuses to start an armed node in anything but FULL sync. A node acquiring
|
||||||
* measurements below are written against.
|
* 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
|
* <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
|
* MAPPING FROM HEIGHT TO KEY SET, not only the current key set, or blocks signed under the old set
|
||||||
@ -62,6 +63,8 @@ import org.junit.jupiter.api.io.TempDir;
|
|||||||
* that the schedule really does express rotation and really does refuse a malformed one, so a
|
* 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.
|
* 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 {
|
public class PqRegistryRotationTest {
|
||||||
|
|
||||||
private static final long CHAIN_ID = 2800L;
|
private static final long CHAIN_ID = 2800L;
|
||||||
@ -172,7 +175,7 @@ public class PqRegistryRotationTest {
|
|||||||
} catch (final IOException e) {
|
} catch (final IOException e) {
|
||||||
throw new IllegalStateException(e);
|
throw new IllegalStateException(e);
|
||||||
}
|
}
|
||||||
return PqRegistryHash.parseSchedule(node, "rotation fixture");
|
return PqRegistryHash.parseSchedule(node, "D-081 fixture");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Every height at which the binding is enforced and could differ across the rotation. */
|
/** Every height at which the binding is enforced and could differ across the rotation. */
|
||||||
@ -202,7 +205,7 @@ public class PqRegistryRotationTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* THE REPAIR: the node holds the WHOLE scheduled history and resolves by height. This
|
* 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
|
* configuration did not exist before the repair, which is why the assertion below could not be
|
||||||
* satisfied by any node at all.
|
* satisfied by any node at all.
|
||||||
*/
|
*/
|
||||||
@ -263,7 +266,7 @@ public class PqRegistryRotationTest {
|
|||||||
final String json =
|
final String json =
|
||||||
"[{\"block\":" + H2 + ",\"hash\":\"0x" + h + "\"},{\"block\":" + H1 + ",\"hash\":\"0x" + h + "\"}]";
|
"[{\"block\":" + H2 + ",\"hash\":\"0x" + h + "\"},{\"block\":" + H1 + ",\"hash\":\"0x" + h + "\"}]";
|
||||||
final JsonNode node = new ObjectMapper().readTree(json);
|
final JsonNode node = new ObjectMapper().readTree(json);
|
||||||
assertThatThrownBy(() -> PqRegistryHash.parseSchedule(node, "rotation fixture"))
|
assertThatThrownBy(() -> PqRegistryHash.parseSchedule(node, "D-081 fixture"))
|
||||||
.isInstanceOf(PqRegistryHash.RegistryConfigException.class)
|
.isInstanceOf(PqRegistryHash.RegistryConfigException.class)
|
||||||
.hasMessageContaining("STRICTLY INCREASING");
|
.hasMessageContaining("STRICTLY INCREASING");
|
||||||
}
|
}
|
||||||
@ -303,10 +306,16 @@ public class PqRegistryRotationTest {
|
|||||||
|
|
||||||
assertThat(complete)
|
assertThat(complete)
|
||||||
.withFailMessage(
|
.withFailMessage(
|
||||||
"ROTATION UNUSABLE: one scheduled rotation at height %d leaves NO node configuration "
|
"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 rotation "
|
+ "that "
|
||||||
+ "scheme must keep the whole HEIGHT-TO-KEY-SET mapping loadable, not only the "
|
+ "satisfies the registry binding at every enforced height. %s. A node that cannot "
|
||||||
+ "current entry.",
|
+ "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,
|
H2,
|
||||||
String.join("; ", report))
|
String.join("; ", report))
|
||||||
.isNotNull();
|
.isNotNull();
|
||||||
@ -345,9 +354,10 @@ public class PqRegistryRotationTest {
|
|||||||
|
|
||||||
assertThat(complete)
|
assertThat(complete)
|
||||||
.withFailMessage(
|
.withFailMessage(
|
||||||
"REVOCATION UNUSABLE: revoking one compromised signer at height %d leaves NO node "
|
"REVOCATION IS NOT USABLE: revoking one signer at height %d leaves NO node "
|
||||||
+ "configuration that satisfies the binding at every enforced height. %s. The "
|
+ "configuration that satisfies the binding at every enforced height. %s. The "
|
||||||
+ "revocation is expressible and is not usable.",
|
+ "revocation is expressible and is not usable: performing it costs the ability to "
|
||||||
|
+ "acquire the chain.",
|
||||||
H2,
|
H2,
|
||||||
String.join("; ", report))
|
String.join("; ", report))
|
||||||
.isNotNull();
|
.isNotNull();
|
||||||
@ -419,8 +429,7 @@ public class PqRegistryRotationTest {
|
|||||||
// Registry objects the test built itself. No operator can do that. What an operator can do is
|
// 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
|
// 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
|
// turns that string into the same set through parseRegistryPaths + loadAuto
|
||||||
// (FalconSealSupport.verifyRegistryBindingOrAbort, the registry-binding block). If that route
|
// (FalconSealSupport.verifyRegistryBindingOrAbort, the D-081 block). If that route were broken
|
||||||
// were broken
|
|
||||||
// the other six would still be green and the capability would still not be usable, which is the
|
// 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".
|
// exact shape of "a green result in a reduced environment is true and worthless".
|
||||||
//
|
//
|
||||||
@ -453,9 +462,10 @@ public class PqRegistryRotationTest {
|
|||||||
}
|
}
|
||||||
assertThat(refused)
|
assertThat(refused)
|
||||||
.withFailMessage(
|
.withFailMessage(
|
||||||
"ROTATION UNUSABLE on the route an operator can actually take: the history list %s "
|
"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 "
|
+ "parses "
|
||||||
+ "express the whole height-to-key-set mapping but the configuration string cannot reach "
|
+ "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.",
|
+ "it, so the rotation remains expressible and not usable.",
|
||||||
configured, refused)
|
configured, refused)
|
||||||
.isEmpty();
|
.isEmpty();
|
||||||
|
|||||||
@ -0,0 +1,127 @@
|
|||||||
|
/* 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
|
* 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
|
* 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;
|
import org.mockito.quality.Strictness;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* THE SECOND HALF OF THE FLEET-RESTART CHAIN DEATH: the heard seals themselves.
|
* 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
|
* <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, measured on an isolated rehearsal
|
* restart of all seven validators kills the chain permanently (rehearsal
|
||||||
* network. The FIRST half of that deadlock was the registry, repaired the same day: it now
|
* repetitie-activare-2026-08-05, isolated chain 330858). The FIRST half of that deadlock was the
|
||||||
* activates at start-up from chain-head state, and all seven nodes reported
|
* registry, repaired the same day: it now activates at start-up from chain-head state, and all
|
||||||
* "anchor activation at STARTUP from head state: SUCCEEDED". The chain died anyway. The refusal
|
* seven nodes reported "anchor activation at STARTUP from head state: SUCCEEDED". The chain died
|
||||||
* only changed shape, from "registry address-bound=false" to "registry address-bound=TRUE ...
|
* anyway. The refusal only changed shape, from "registry address-bound=false" to "registry
|
||||||
* Heard 0 seal(s)", frozen 150 s then 298 s.
|
* 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
|
* <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
|
* the head block, and those are never replayed after a restart. They exist nowhere else: the head's
|
||||||
@ -100,6 +100,8 @@ import org.mockito.quality.Strictness;
|
|||||||
* real simultaneous restart with a binary built from this tree. That needs the rehearsal network and
|
* 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.
|
* 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 {
|
public class PqSealPersistenceTest {
|
||||||
|
|
||||||
/** Anchor activation height H. */
|
/** Anchor activation height H. */
|
||||||
@ -111,7 +113,7 @@ public class PqSealPersistenceTest {
|
|||||||
/** Height from which the staged threshold K is in force. */
|
/** Height from which the staged threshold K is in force. */
|
||||||
private static final long K_AT = H + 10L;
|
private static final long K_AT = H + 10L;
|
||||||
|
|
||||||
/** The threshold with full margin at N=7: K=3, so the margin equals f. */
|
/** 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 K = 3;
|
||||||
|
|
||||||
private static final int N = 7;
|
private static final int N = 7;
|
||||||
@ -136,7 +138,7 @@ public class PqSealPersistenceTest {
|
|||||||
public void setUp() throws Exception {
|
public void setUp() throws Exception {
|
||||||
dataDirectory = Files.createDirectories(tmp.resolve("besu-data"));
|
dataDirectory = Files.createDirectories(tmp.resolve("besu-data"));
|
||||||
|
|
||||||
// REGISTRY BINDING: v2, proof-bound, bound at H. See PqV2Fixture.
|
// AERE D-146 (2026-08-06): v2, proof-bound, bound at H. See PqV2Fixture.
|
||||||
final KeccakDigest kd = new KeccakDigest(256);
|
final KeccakDigest kd = new KeccakDigest(256);
|
||||||
final StringBuilder manifest =
|
final StringBuilder manifest =
|
||||||
new StringBuilder("{\"config\":{\"aereFalconRegistry\":{")
|
new StringBuilder("{\"config\":{\"aereFalconRegistry\":{")
|
||||||
@ -257,8 +259,8 @@ public class PqSealPersistenceTest {
|
|||||||
/**
|
/**
|
||||||
* Persisting seals is only defensible because a seal is SELF-AUTHENTICATING: it is re-verified at
|
* 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
|
* 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 the unbound-registry defect in another coat -
|
* that were not so, the file would be exactly defect A8 in another coat - state believed because
|
||||||
* state believed because it sits in a file a node can be pointed at.
|
* 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:
|
* <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;
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AERE SIGNED HEIGHT. THE SILENT DEFERRAL, and it is the worst of the three because nothing shows
|
* AERE D-B (2026-08-06). THE SILENT DEFERRAL, and it is the worst of the three because nothing shows
|
||||||
* it.
|
* it.
|
||||||
*
|
*
|
||||||
* <p>WHAT IS SUPPOSED TO BE TRUE. Every row of a v2 registry carries two signatures - a Falcon
|
* <p>WHAT IS SUPPOSED TO BE TRUE. Every row of a v2 registry carries two signatures - a Falcon
|
||||||
@ -46,8 +46,7 @@ import org.junit.jupiter.api.io.TempDir;
|
|||||||
*
|
*
|
||||||
* <p>WHAT WAS ACTUALLY TRUE UNTIL THIS FILE. {@code bindHeight} was never compared with the {@code
|
* <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
|
* block} of the schedule entry that puts the registry in force. Not anywhere. The two numbers had
|
||||||
* been in the same lexical scope ever since the schedule became height-indexed, and were never put
|
* been in the same lexical scope since D-081 and were never put on the same expression.
|
||||||
* on the same expression.
|
|
||||||
*
|
*
|
||||||
* <p>WHY THE HASH DOES NOT CATCH IT, which is the part that makes this invisible rather than merely
|
* <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
|
* missing. {@code bindHeight} is INSIDE the v2 pre-image, so it is covered by the hash - and that is
|
||||||
@ -297,7 +296,7 @@ public class PqSignedHeightTest {
|
|||||||
// The honest limitation has to be IN the message, or an operator will read this as a
|
// The honest limitation has to be IN the message, or an operator will read this as a
|
||||||
// consensus guarantee it is not.
|
// consensus guarantee it is not.
|
||||||
.hasMessageContaining("DETECTION on this node only")
|
.hasMessageContaining("DETECTION on this node only")
|
||||||
.hasMessageContaining("all seven nodes and in the same change");
|
.hasMessageContaining("on every node and in the same change");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@ -33,7 +33,7 @@ import org.junit.jupiter.api.Test;
|
|||||||
import org.junit.jupiter.api.io.TempDir;
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AERE ROTATION HISTORY AT STARTUP. AFTER THE FIRST ROTATION, NO NODE COULD BE RESTARTED WITH ITS OWN CORRECT
|
* AERE D-A (2026-08-06). AFTER THE FIRST ROTATION, NO NODE COULD BE RESTARTED WITH ITS OWN CORRECT
|
||||||
* CONFIGURATION.
|
* CONFIGURATION.
|
||||||
*
|
*
|
||||||
* <p>WHAT WAS MEASURED, and it was measured twice: once during the rotation rehearsal on a network
|
* <p>WHAT WAS MEASURED, and it was measured twice: once during the rotation rehearsal on a network
|
||||||
@ -45,9 +45,8 @@ import org.junit.jupiter.api.io.TempDir;
|
|||||||
* <p>THE DEFECT WAS THE ORDER OF TWO BLOCKS OF CODE. {@code
|
* <p>THE DEFECT WAS THE ORDER OF TWO BLOCKS OF CODE. {@code
|
||||||
* FalconSealSupport.verifyRegistryBindingOrAbort} loaded ONE registry, the primary, and handed it to
|
* 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
|
* the guard. The history list was read FORTY-ONE LINES FURTHER DOWN, to build the height-resolved
|
||||||
* set the height-indexed registry change introduced. So the refusal was thrown before the code that
|
* set D-081 introduced. So the refusal was thrown before the code that knew the answer had run. The
|
||||||
* knew the answer had run. The guard was not wrong about what it compared; it was never shown the
|
* guard was not wrong about what it compared; it was never shown the other files.
|
||||||
* other files.
|
|
||||||
*
|
*
|
||||||
* <p>WHY IT BITES EXACTLY AFTER A ROTATION AND NEVER BEFORE. The primary registry is the genesis
|
* <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
|
* 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;
|
import org.bouncycastle.pqc.crypto.falcon.FalconSigner;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AERE REGISTRY BINDING (2026-08-06). The shared probe fleet every arming fixture is now built
|
* AERE D-146 (2026-08-06). The shared probe fleet every arming fixture is now built from, and the
|
||||||
* from, and the reason it had to exist.
|
* reason it had to exist.
|
||||||
*
|
*
|
||||||
* <p>WHAT IT REPLACED, and why the replacement is not cosmetic. Until 2026-08-06 seven separate
|
* <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 +
|
* 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
|
* i)}. Those addresses are arithmetic, not keys: no secp256k1 private key produces them, so no
|
||||||
* validator can ever sign a binding claim for one. The moment {@code AERE-PQC-REG-ARM-02} was wired
|
* validator can ever sign a D-146 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,
|
* 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:
|
* 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.
|
* 35 tests across 7 classes, every failure carrying AERE-PQC-REG-ARM-02.
|
||||||
@ -215,9 +215,8 @@ public final class PqV2Fixture {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The bytes the genesis anchor slot commits to for row {@code i}: {@code address || publicKey},
|
* The bytes the genesis anchor slot commits to for row {@code i}: {@code address || publicKey},
|
||||||
* which is what {@code hashV0Legacy} accumulates. Unchanged by the v2 binding work - the proofs
|
* which is what {@code hashV0Legacy} accumulates. Unchanged by D-146 - the proofs are outside the
|
||||||
* are outside the legacy pre-image - and kept here so a fixture cannot drift from the row it just
|
* legacy pre-image - and kept here so a fixture cannot drift from the row it just wrote.
|
||||||
* wrote.
|
|
||||||
*
|
*
|
||||||
* @param i the row index
|
* @param i the row index
|
||||||
* @return the anchored pre-image bytes for that row
|
* @return the anchored pre-image bytes for that row
|
||||||
|
|||||||
@ -0,0 +1,101 @@
|
|||||||
|
/*
|
||||||
|
* 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,145 @@
|
|||||||
|
/* 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,6 +22,7 @@ package org.hyperledger.besu.consensus.qbft.core.network;
|
|||||||
|
|
||||||
import org.hyperledger.besu.consensus.common.bft.ConsensusRoundIdentifier;
|
import org.hyperledger.besu.consensus.common.bft.ConsensusRoundIdentifier;
|
||||||
import org.hyperledger.besu.consensus.common.bft.FalconSeal;
|
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.network.ValidatorMulticaster;
|
||||||
import org.hyperledger.besu.consensus.common.bft.payload.SignedData;
|
import org.hyperledger.besu.consensus.common.bft.payload.SignedData;
|
||||||
import org.hyperledger.besu.consensus.qbft.core.messagedata.CommitMessageData;
|
import org.hyperledger.besu.consensus.qbft.core.messagedata.CommitMessageData;
|
||||||
@ -103,8 +104,27 @@ public class QbftMessageTransmitter {
|
|||||||
* @param digest the digest
|
* @param digest the digest
|
||||||
*/
|
*/
|
||||||
public void multicastPrepare(final ConsensusRoundIdentifier roundIdentifier, final Hash 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 {
|
try {
|
||||||
final Prepare data = messageFactory.createPrepare(roundIdentifier, digest);
|
final Prepare data = messageFactory.createPrepare(roundIdentifier, digest, falconSeal);
|
||||||
|
|
||||||
final PrepareMessageData message = PrepareMessageData.create(data);
|
final PrepareMessageData message = PrepareMessageData.create(data);
|
||||||
|
|
||||||
@ -141,8 +161,28 @@ public class QbftMessageTransmitter {
|
|||||||
final Hash digest,
|
final Hash digest,
|
||||||
final SECPSignature commitSeal,
|
final SECPSignature commitSeal,
|
||||||
final Optional<FalconSeal> falconSeal) {
|
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 {
|
try {
|
||||||
final Commit data = messageFactory.createCommit(roundIdentifier, digest, commitSeal, falconSeal);
|
final Commit data =
|
||||||
|
messageFactory.createCommit(roundIdentifier, digest, commitSeal, falconSeal, extraSeals);
|
||||||
|
|
||||||
final CommitMessageData message = CommitMessageData.create(data);
|
final CommitMessageData message = CommitMessageData.create(data);
|
||||||
|
|
||||||
|
|||||||
@ -22,6 +22,9 @@ package org.hyperledger.besu.consensus.qbft.core.payload;
|
|||||||
|
|
||||||
import org.hyperledger.besu.consensus.common.bft.ConsensusRoundIdentifier;
|
import org.hyperledger.besu.consensus.common.bft.ConsensusRoundIdentifier;
|
||||||
import org.hyperledger.besu.consensus.common.bft.FalconSeal;
|
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.common.bft.payload.Payload;
|
||||||
import org.hyperledger.besu.consensus.qbft.core.messagedata.QbftV1;
|
import org.hyperledger.besu.consensus.qbft.core.messagedata.QbftV1;
|
||||||
import org.hyperledger.besu.crypto.SECPSignature;
|
import org.hyperledger.besu.crypto.SECPSignature;
|
||||||
@ -31,6 +34,7 @@ import org.hyperledger.besu.ethereum.rlp.RLPException;
|
|||||||
import org.hyperledger.besu.ethereum.rlp.RLPInput;
|
import org.hyperledger.besu.ethereum.rlp.RLPInput;
|
||||||
import org.hyperledger.besu.ethereum.rlp.RLPOutput;
|
import org.hyperledger.besu.ethereum.rlp.RLPOutput;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.StringJoiner;
|
import java.util.StringJoiner;
|
||||||
@ -56,6 +60,10 @@ public class CommitPayload extends QbftPayload {
|
|||||||
private final Hash digest;
|
private final Hash digest;
|
||||||
private final SECPSignature commitSeal;
|
private final SECPSignature commitSeal;
|
||||||
private final Optional<FalconSeal> falconSeal;
|
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).
|
* Instantiates a new Commit payload (no Falcon seal).
|
||||||
@ -84,10 +92,72 @@ public class CommitPayload extends QbftPayload {
|
|||||||
final Hash digest,
|
final Hash digest,
|
||||||
final SECPSignature commitSeal,
|
final SECPSignature commitSeal,
|
||||||
final Optional<FalconSeal> falconSeal) {
|
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.roundIdentifier = roundIdentifier;
|
||||||
this.digest = digest;
|
this.digest = digest;
|
||||||
this.commitSeal = commitSeal;
|
this.commitSeal = commitSeal;
|
||||||
this.falconSeal = falconSeal == null ? Optional.empty() : falconSeal;
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -149,9 +219,37 @@ public class CommitPayload extends QbftPayload {
|
|||||||
payloadRlp.leaveList();
|
payloadRlp.leaveList();
|
||||||
falconSeal = Optional.of(new FalconSeal(idx, sig));
|
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();
|
payloadRlp.leaveList();
|
||||||
|
|
||||||
final CommitPayload payload = new CommitPayload(roundIdentifier, digest, commitSeal, falconSeal);
|
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());
|
||||||
|
}
|
||||||
|
|
||||||
// AERE FIX-MALEABILITATE: exactly one encoding is accepted for a given payload value. This
|
// 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
|
// catches everything the RLP reader itself would tolerate, including any element the decode
|
||||||
@ -188,6 +286,12 @@ public class CommitPayload extends QbftPayload {
|
|||||||
rlpOutput.writeBytes(fs.getSignature());
|
rlpOutput.writeBytes(fs.getSignature());
|
||||||
rlpOutput.endList();
|
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();
|
rlpOutput.endList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -223,6 +327,15 @@ public class CommitPayload extends QbftPayload {
|
|||||||
return falconSeal;
|
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
|
@Override
|
||||||
public ConsensusRoundIdentifier getRoundIdentifier() {
|
public ConsensusRoundIdentifier getRoundIdentifier() {
|
||||||
return roundIdentifier;
|
return roundIdentifier;
|
||||||
@ -240,12 +353,13 @@ public class CommitPayload extends QbftPayload {
|
|||||||
return Objects.equals(roundIdentifier, that.roundIdentifier)
|
return Objects.equals(roundIdentifier, that.roundIdentifier)
|
||||||
&& Objects.equals(digest, that.digest)
|
&& Objects.equals(digest, that.digest)
|
||||||
&& Objects.equals(commitSeal, that.commitSeal)
|
&& Objects.equals(commitSeal, that.commitSeal)
|
||||||
&& Objects.equals(falconSeal, that.falconSeal);
|
&& Objects.equals(falconSeal, that.falconSeal)
|
||||||
|
&& Objects.equals(extraSeals, that.extraSeals);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public int hashCode() {
|
public int hashCode() {
|
||||||
return Objects.hash(roundIdentifier, digest, commitSeal, falconSeal);
|
return Objects.hash(roundIdentifier, digest, commitSeal, falconSeal, extraSeals);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@ -255,6 +369,7 @@ public class CommitPayload extends QbftPayload {
|
|||||||
.add("digest=" + digest)
|
.add("digest=" + digest)
|
||||||
.add("commitSeal=" + commitSeal)
|
.add("commitSeal=" + commitSeal)
|
||||||
.add("falconSeal=" + falconSeal)
|
.add("falconSeal=" + falconSeal)
|
||||||
|
.add("extraSeals=" + extraSeals.size())
|
||||||
.toString();
|
.toString();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -107,7 +107,27 @@ public class MessageFactory {
|
|||||||
* @return the prepare
|
* @return the prepare
|
||||||
*/
|
*/
|
||||||
public Prepare createPrepare(final ConsensusRoundIdentifier roundIdentifier, final Hash digest) {
|
public Prepare createPrepare(final ConsensusRoundIdentifier roundIdentifier, final Hash digest) {
|
||||||
final PreparePayload payload = new PreparePayload(roundIdentifier, 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);
|
||||||
return new Prepare(createSignedMessage(payload));
|
return new Prepare(createSignedMessage(payload));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -141,8 +161,29 @@ public class MessageFactory {
|
|||||||
final Hash digest,
|
final Hash digest,
|
||||||
final SECPSignature commitSeal,
|
final SECPSignature commitSeal,
|
||||||
final Optional<FalconSeal> falconSeal) {
|
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 =
|
final CommitPayload payload =
|
||||||
new CommitPayload(roundIdentifier, digest, commitSeal, falconSeal);
|
new CommitPayload(roundIdentifier, digest, commitSeal, falconSeal, extraSeals);
|
||||||
return new Commit(createSignedMessage(payload));
|
return new Commit(createSignedMessage(payload));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,204 @@
|
|||||||
|
/*
|
||||||
|
* 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,372 @@
|
|||||||
|
/*
|
||||||
|
* 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,16 +282,52 @@ public class QbftRound {
|
|||||||
private void sendPrepare(final QbftBlock block) {
|
private void sendPrepare(final QbftBlock block) {
|
||||||
LOG.debug("Sending prepare message. round={}", roundState.getRoundIdentifier());
|
LOG.debug("Sending prepare message. round={}", roundState.getRoundIdentifier());
|
||||||
try {
|
try {
|
||||||
|
// AERE PQ PREPARE (2026-08-28), step 2: the seal is computed EXACTLY ONCE here and is
|
||||||
|
// 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 =
|
final Prepare localPrepareMessage =
|
||||||
messageFactory.createPrepare(getRoundIdentifier(), block.getHash());
|
falconSeal.isPresent()
|
||||||
|
? messageFactory.createPrepare(getRoundIdentifier(), block.getHash(), falconSeal)
|
||||||
|
: messageFactory.createPrepare(getRoundIdentifier(), block.getHash());
|
||||||
peerIsPrepared(localPrepareMessage);
|
peerIsPrepared(localPrepareMessage);
|
||||||
transmitter.multicastPrepare(
|
if (falconSeal.isPresent()) {
|
||||||
localPrepareMessage.getRoundIdentifier(), localPrepareMessage.getDigest());
|
transmitter.multicastPrepare(
|
||||||
|
localPrepareMessage.getRoundIdentifier(), localPrepareMessage.getDigest(), falconSeal);
|
||||||
|
} else {
|
||||||
|
transmitter.multicastPrepare(
|
||||||
|
localPrepareMessage.getRoundIdentifier(), localPrepareMessage.getDigest());
|
||||||
|
}
|
||||||
} catch (final SecurityModuleException e) {
|
} catch (final SecurityModuleException e) {
|
||||||
LOG.warn("Failed to create a signed Prepare; {}", e.getMessage());
|
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.
|
* Handle prepare message.
|
||||||
*
|
*
|
||||||
@ -347,11 +383,17 @@ public class QbftRound {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
final Optional<FalconSeal> falconSeal = falconSealFor(block, commitHash);
|
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.
|
// There are times handling a proposed block is enough to enter prepared.
|
||||||
if (wasPrepared != roundState.isPrepared()) {
|
if (wasPrepared != roundState.isPrepared()) {
|
||||||
LOG.debug("Sending commit message. round={}", roundState.getRoundIdentifier());
|
LOG.debug("Sending commit message. round={}", roundState.getRoundIdentifier());
|
||||||
if (falconSeal.isPresent()) {
|
if (!extraSeals.isEmpty()) {
|
||||||
|
transmitter.multicastCommit(
|
||||||
|
getRoundIdentifier(), block.getHash(), commitSeal, falconSeal, extraSeals);
|
||||||
|
} else if (falconSeal.isPresent()) {
|
||||||
transmitter.multicastCommit(getRoundIdentifier(), block.getHash(), commitSeal, falconSeal);
|
transmitter.multicastCommit(getRoundIdentifier(), block.getHash(), commitSeal, falconSeal);
|
||||||
} else {
|
} else {
|
||||||
transmitter.multicastCommit(getRoundIdentifier(), block.getHash(), commitSeal);
|
transmitter.multicastCommit(getRoundIdentifier(), block.getHash(), commitSeal);
|
||||||
@ -368,7 +410,8 @@ public class QbftRound {
|
|||||||
roundState.getRoundIdentifier(),
|
roundState.getRoundIdentifier(),
|
||||||
msg.getBlock().getHash(),
|
msg.getBlock().getHash(),
|
||||||
commitSeal,
|
commitSeal,
|
||||||
falconSeal)
|
falconSeal,
|
||||||
|
extraSeals)
|
||||||
: messageFactory.createCommit(
|
: messageFactory.createCommit(
|
||||||
roundState.getRoundIdentifier(), msg.getBlock().getHash(), commitSeal);
|
roundState.getRoundIdentifier(), msg.getBlock().getHash(), commitSeal);
|
||||||
roundState.addCommitMessage(localCommitMessage);
|
roundState.addCommitMessage(localCommitMessage);
|
||||||
@ -398,7 +441,13 @@ public class QbftRound {
|
|||||||
final Hash commitHash = commitHashFor(block);
|
final Hash commitHash = commitHashFor(block);
|
||||||
final SECPSignature commitSeal = nodeKey.sign(Bytes32.wrap(commitHash.getBytes()));
|
final SECPSignature commitSeal = nodeKey.sign(Bytes32.wrap(commitHash.getBytes()));
|
||||||
final Optional<FalconSeal> falconSeal = falconSealFor(block, commitHash);
|
final Optional<FalconSeal> falconSeal = falconSealFor(block, commitHash);
|
||||||
if (falconSeal.isPresent()) {
|
// 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()) {
|
||||||
transmitter.multicastCommit(getRoundIdentifier(), block.getHash(), commitSeal, falconSeal);
|
transmitter.multicastCommit(getRoundIdentifier(), block.getHash(), commitSeal, falconSeal);
|
||||||
} else {
|
} else {
|
||||||
transmitter.multicastCommit(getRoundIdentifier(), block.getHash(), commitSeal);
|
transmitter.multicastCommit(getRoundIdentifier(), block.getHash(), commitSeal);
|
||||||
@ -433,8 +482,14 @@ public class QbftRound {
|
|||||||
private void importBlockToChain() {
|
private void importBlockToChain() {
|
||||||
|
|
||||||
// AERE hybrid PQC: pass the gossiped Falcon seals collected from commit messages so the block
|
// AERE hybrid PQC: pass the gossiped Falcon seals collected from commit messages so the block
|
||||||
// assembler can embed a >= 2f+1 Falcon quorum certificate. When no Falcon seals were gossiped
|
// assembler can embed the post-quantum certificate. When no Falcon seals were gossiped (Falcon
|
||||||
// (Falcon disabled), fall back to the unchanged ECDSA-only sealing path.
|
// 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.
|
||||||
final Collection<FalconSeal> falconSeals = roundState.getFalconSeals();
|
final Collection<FalconSeal> falconSeals = roundState.getFalconSeals();
|
||||||
final QbftBlock blockToImport =
|
final QbftBlock blockToImport =
|
||||||
falconSeals.isEmpty()
|
falconSeals.isEmpty()
|
||||||
@ -509,6 +564,31 @@ public class QbftRound {
|
|||||||
return pqOnchainHash;
|
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) {
|
private Optional<FalconSeal> falconSealFor(final QbftBlock block, final Hash commitHash) {
|
||||||
// FalconSealSupport.sign never throws (any fault is swallowed and logged), and returns empty
|
// 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.
|
// when this node holds no Falcon signing key, so the ECDSA commit path is never affected.
|
||||||
@ -527,16 +607,10 @@ public class QbftRound {
|
|||||||
// not rebuild it. Every node flips at the same height, since the height is a pure function of
|
// 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 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.
|
// 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();
|
final long blockNumber = block.getHeader().getNumber();
|
||||||
final Bytes32 message;
|
return FalconSealSupport.instance().sign(blockNumber, pqSealMessageFor(block, commitHash));
|
||||||
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) {
|
private QbftBlock createCommitBlock(final QbftBlock block) {
|
||||||
|
|||||||
@ -0,0 +1,180 @@
|
|||||||
|
/*
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,332 @@
|
|||||||
|
/*
|
||||||
|
* 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,206 @@
|
|||||||
|
/*
|
||||||
|
* 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,138 @@
|
|||||||
|
/*
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,277 @@
|
|||||||
|
/* 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,209 @@
|
|||||||
|
/*
|
||||||
|
* Copyright contributors to Besu.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
|
||||||
|
* the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
|
||||||
|
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations under the License.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
package org.hyperledger.besu.consensus.qbft.core.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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,195 @@
|
|||||||
|
/*
|
||||||
|
* 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,152 @@
|
|||||||
|
/* 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,159 @@
|
|||||||
|
/* 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,108 @@
|
|||||||
|
/* 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,295 @@
|
|||||||
|
/* 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,180 @@
|
|||||||
|
/*
|
||||||
|
* 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,266 @@
|
|||||||
|
/*
|
||||||
|
* 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,118 @@
|
|||||||
|
/*
|
||||||
|
* 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,298 @@
|
|||||||
|
/*
|
||||||
|
* 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,6 +28,7 @@ import org.hyperledger.besu.consensus.common.bft.FalconSealSupport;
|
|||||||
import org.hyperledger.besu.consensus.common.bft.PqAnchorConfig;
|
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.BftCoinbaseValidationRule;
|
||||||
import org.hyperledger.besu.consensus.common.bft.headervalidationrules.BftCommitSealsValidationRule;
|
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.FalconSealValidationRule;
|
||||||
import org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorDigestAttachedRule;
|
import org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorDigestAttachedRule;
|
||||||
import org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorDigestRule;
|
import org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorDigestRule;
|
||||||
@ -102,12 +103,11 @@ public class QbftBlockHeaderValidationRulesetFactory {
|
|||||||
* therefore bit-for-bit equivalent to today's on the whole existing chain, which is the condition
|
* therefore bit-for-bit equivalent to today's on the whole existing chain, which is the condition
|
||||||
* for warming it on a live node.
|
* for warming it on a live node.
|
||||||
*
|
*
|
||||||
* <p>The rule COUNT goes from 11 to 16 (the per-block registry binding adds one, was 13;
|
* <p>The rule COUNT goes from 11 to 16 (A8 adds the registry-binding rule, was 13; SINCRONIZARE
|
||||||
* SINCRONIZARE adds the attached copy of the digest rule, was 14; OPTIUNI-URGENTA adds the
|
* adds the attached copy of the digest rule, was 14; OPTIUNI-URGENTA adds the emergency
|
||||||
* emergency announcement rule, which can never reject, was 15) (14 unconditional plus the
|
* announcement rule, which can never reject, was 15) (14 unconditional plus the conditional timestamp rule). The
|
||||||
* conditional timestamp rule). The original design note said 12 because it assumed the legacy
|
* design note said 12 because it assumed the legacy Falcon rule would be deleted; retiring it by
|
||||||
* Falcon rule would be deleted; retiring it by height instead is what keeps behaviour below H
|
* height instead is what keeps behaviour below H identical, so it stays in the list.
|
||||||
* identical, so it stays in the list.
|
|
||||||
*
|
*
|
||||||
* @param minimumTimeBetweenBlocks the minimum amount of time that must elapse between blocks.
|
* @param minimumTimeBetweenBlocks the minimum amount of time that must elapse between blocks.
|
||||||
* @param useValidatorContract whether validator selection is using a validator contract
|
* @param useValidatorContract whether validator selection is using a validator contract
|
||||||
@ -143,6 +143,16 @@ public class QbftBlockHeaderValidationRulesetFactory {
|
|||||||
new GasLimitRangeAndDeltaValidationRule(
|
new GasLimitRangeAndDeltaValidationRule(
|
||||||
DEFAULT_MIN_GAS_LIMIT, DEFAULT_MAX_GAS_LIMIT, baseFeeMarket))
|
DEFAULT_MIN_GAS_LIMIT, DEFAULT_MAX_GAS_LIMIT, baseFeeMarket))
|
||||||
.addRule(new TimestampBoundedByFutureParameter(1))
|
.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(
|
.addRule(
|
||||||
new ConstantFieldValidationRule<>(
|
new ConstantFieldValidationRule<>(
|
||||||
"MixHash", BlockHeader::getMixHash, BftHelpers.EXPECTED_MIX_HASH))
|
"MixHash", BlockHeader::getMixHash, BftHelpers.EXPECTED_MIX_HASH))
|
||||||
@ -173,8 +183,8 @@ public class QbftBlockHeaderValidationRulesetFactory {
|
|||||||
.addRule(new PqAnchorDigestAttachedRule(pqAnchorConfig))
|
.addRule(new PqAnchorDigestAttachedRule(pqAnchorConfig))
|
||||||
// AERE ANCORA-V2 R2: attached, full validation only.
|
// AERE ANCORA-V2 R2: attached, full validation only.
|
||||||
.addRule(new PqAnchorSealsRule(pqAnchorConfig))
|
.addRule(new PqAnchorSealsRule(pqAnchorConfig))
|
||||||
// AERE REGISTRY BINDING, per-block half: the registry this node runs must be the
|
// AERE A8 per-block half: the registry this node runs must be the registry
|
||||||
// registry config.pqRegistryHash requires AT THIS HEIGHT. The startup guard answers
|
// config.pqRegistryHash requires AT THIS HEIGHT. The startup guard answers
|
||||||
// that once, against the head that existed at startup; a rotation entry in
|
// 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.
|
// the schedule can pass underneath a running node and never be noticed.
|
||||||
// Inert when no schedule is configured, which is chain 2800 today.
|
// Inert when no schedule is configured, which is chain 2800 today.
|
||||||
|
|||||||
@ -161,7 +161,7 @@ public class QbftBlockCreatorAdaptor implements QbftBlockCreator {
|
|||||||
final Hash commitHash =
|
final Hash commitHash =
|
||||||
new BftBlockHashing(bftExtraDataCodec).calculateDataHashForCommittedSeal(sealedHeader);
|
new BftBlockHashing(bftExtraDataCodec).calculateDataHashForCommittedSeal(sealedHeader);
|
||||||
|
|
||||||
// AERE audit fix, ELIGIBLE-SIGNER BINDING: restrict the embedded certificate to ELIGIBLE
|
// AERE audit fix (AUD-CONSENSUS-1 / -2): restrict the embedded certificate to ELIGIBLE
|
||||||
// signers (current validators carried in extraData INTERSECT the address-bound registry), so
|
// 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.
|
// the assembler never embeds a seal the header rule would later reject as ineligible.
|
||||||
final Set<Address> registered = pqc.registeredValidatorAddresses();
|
final Set<Address> registered = pqc.registeredValidatorAddresses();
|
||||||
@ -172,11 +172,9 @@ public class QbftBlockCreatorAdaptor implements QbftBlockCreator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// AERE HEADER GROWTH 2026-08-08: the interval gate. Measured on chain 2800 the same day: with
|
// AERE DISC 2026-08-08: the interval gate. Measured on chain 2800 the same day: with all
|
||||||
// every validator attaching, this assembler wrote FIVE seals into EVERY header, taking it from
|
// seven validators attaching, this assembler wrote FIVE seals into EVERY header, 525 -> 3844
|
||||||
// 525 to 3844 bytes. That is roughly SEVEN TIMES the header bytes stored per block, on every
|
// bytes, about 200 GB per node per year against 12 GB free on the tightest host.
|
||||||
// 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
|
// 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
|
// that runs BEFORE the activation height, had neither, so the controls were unreachable
|
||||||
@ -205,11 +203,10 @@ public class QbftBlockCreatorAdaptor implements QbftBlockCreator {
|
|||||||
// can sign AND is itself an eligible signer, attach its own seal so a single-signer
|
// can sign AND is itself an eligible signer, attach its own seal so a single-signer
|
||||||
// certificate is still produced.
|
// certificate is still produced.
|
||||||
if (quorumCert.isEmpty() && pqc.signingEnabled()) {
|
if (quorumCert.isEmpty() && pqc.signingEnabled()) {
|
||||||
// REGISTRY HEIGHT BINDING (2026-08-06): this is the ONE registry question in the stack with
|
// D2 (2026-08-06): this is the ONE registry question in the stack with no honest height -
|
||||||
// no honest height - "am I, right now, an eligible signer", asked before signing with the
|
// "am I, right now, an eligible signer", asked before signing with the single private key
|
||||||
// single private key this process holds. It gets its own name rather than a fabricated
|
// this process holds. It gets its own name rather than a fabricated height, so that no
|
||||||
// height, so that no future reader mistakes it for a verification path. See
|
// future reader mistakes it for a verification path. See FalconSealSupport#localSigningAddress.
|
||||||
// FalconSealSupport#localSigningAddress.
|
|
||||||
final Address self = pqc.localSigningAddress();
|
final Address self = pqc.localSigningAddress();
|
||||||
if (self != null && eligible.contains(self)) {
|
if (self != null && eligible.contains(self)) {
|
||||||
// AERE FIX-OPRIRE-CONSENS (b): height-gated like every other attachment point.
|
// AERE FIX-OPRIRE-CONSENS (b): height-gated like every other attachment point.
|
||||||
@ -249,12 +246,11 @@ public class QbftBlockCreatorAdaptor implements QbftBlockCreator {
|
|||||||
return new QbftBlockAdaptor(sealedBesuBlock);
|
return new QbftBlockAdaptor(sealedBesuBlock);
|
||||||
}
|
}
|
||||||
|
|
||||||
// REGISTRY HEIGHT BINDING (2026-08-06): takes the height of the block being sealed. The seals
|
// D2 (2026-08-06): takes the height of the block being sealed. The seals gathered here are over
|
||||||
// gathered here are over THIS block's committed-seal hash, so the height is this block's own and
|
// THIS block's committed-seal hash, so the height is this block's own and is known at the call
|
||||||
// is known at the call site. It matters at exactly one moment - a rotation height - where
|
// site. It matters at exactly one moment - a rotation height - where assembling a certificate
|
||||||
// assembling a certificate under the head key set while every validator checks it under the
|
// under the head key set while every validator checks it under the scheduled one produces a block
|
||||||
// scheduled one produces a block every other node rejects, with nothing in any log naming the
|
// the fleet rejects, with nothing in any log naming the reason.
|
||||||
// reason.
|
|
||||||
private static List<FalconSeal> verifiedDistinctSeals(
|
private static List<FalconSeal> verifiedDistinctSeals(
|
||||||
final FalconSealSupport pqc,
|
final FalconSealSupport pqc,
|
||||||
final long blockNumber,
|
final long blockNumber,
|
||||||
@ -279,7 +275,7 @@ public class QbftBlockCreatorAdaptor implements QbftBlockCreator {
|
|||||||
}
|
}
|
||||||
// Bind each seal to its registered validator address and keep it only if that address is an
|
// 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.
|
// eligible signer (a current validator with a registered key), de-duplicated by address.
|
||||||
// REGISTRY HEIGHT BINDING, the OWN-HEAD door: this is the block this node is sealing now.
|
// D2 (b-v2): the OWN-HEAD door. This is the block this node is sealing right now.
|
||||||
final Address signer = pqc.addressForIndexAtOwnHead(blockNumber, seal.getValidatorIndex());
|
final Address signer = pqc.addressForIndexAtOwnHead(blockNumber, seal.getValidatorIndex());
|
||||||
if (signer == null || !eligible.contains(signer) || seen.contains(signer)) {
|
if (signer == null || !eligible.contains(signer) || seen.contains(signer)) {
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@ -0,0 +1,135 @@
|
|||||||
|
/*
|
||||||
|
* 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
|
* 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
|
* the License. You may obtain a copy of the License at
|
||||||
@ -43,12 +43,27 @@ import org.slf4j.LoggerFactory;
|
|||||||
/**
|
/**
|
||||||
* Verifies the PARALLEL Falcon-512 post-quantum QUORUM CERTIFICATE embedded in a QBFT block header.
|
* 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
|
* <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),
|
* 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.
|
* aggregated by the block assembler into the header's parallel Falcon-seal list.
|
||||||
*
|
*
|
||||||
* <p><b>AERE audit fix, ELIGIBLE-SIGNER BINDING (2026-07-18).</b> Both the Falcon quorum threshold
|
* <p><b>AERE audit fix (AUD-CONSENSUS-1 / AUD-CONSENSUS-2, 2026-07-18).</b> Both the Falcon quorum
|
||||||
* AND the counted-seal set are now bound to ONE well-defined set:
|
* threshold AND the counted-seal set are now bound to ONE well-defined set:
|
||||||
*
|
*
|
||||||
* <pre>
|
* <pre>
|
||||||
* eligibleSigners = currentValidators (getValidatorsAfterBlock(parent))
|
* eligibleSigners = currentValidators (getValidatorsAfterBlock(parent))
|
||||||
@ -87,13 +102,12 @@ import org.slf4j.LoggerFactory;
|
|||||||
* boundary, i.e. arm time), NEVER an implicit accept.
|
* boundary, i.e. arm time), NEVER an implicit accept.
|
||||||
* </ul>
|
* </ul>
|
||||||
*
|
*
|
||||||
* <p>ARMING INVARIANT (eligible-signer binding): blocking should be armed only when the registry
|
* <p>ARMING INVARIANT (AUD-CONSENSUS-1): blocking should be armed only when the registry COVERS the
|
||||||
* COVERS the validator set (every current validator has a Falcon key), so that {@code
|
* validator set (every current validator has a Falcon key), so that {@code eligibleSigners ==
|
||||||
* eligibleSigners == currentValidators} and the Falcon quorum equals the ECDSA quorum with full
|
* currentValidators} and the Falcon quorum equals the ECDSA quorum with full fault margin. When
|
||||||
* fault margin. When coverage is incomplete the rule stays LIVE on the intersection (it does not
|
* coverage is incomplete the rule stays LIVE on the intersection (it does not halt) but logs a LOUD
|
||||||
* halt) but logs a LOUD warning that the margin is reduced and a registry re-anchor is required.
|
* warning that the margin is reduced and a registry re-anchor is required. This is the "either the
|
||||||
* This is the "either the intersection keeps it live, or it fail-closes at arm time, never as a
|
* intersection keeps it live, or it fail-closes at arm time, never as a silent halt" contract.
|
||||||
* silent halt" contract.
|
|
||||||
*
|
*
|
||||||
* <p>STAGE-2 LATE-ANCHOR activation: when the node is configured with a late-anchor manifest
|
* <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
|
* ({@code aere.falcon.manifest} + {@code aere.falcon.anchor.address}) on a chain that launched
|
||||||
@ -113,9 +127,8 @@ public class FalconSealValidationRule implements AttachedBlockHeaderValidationRu
|
|||||||
* <p>WHY THIS EXISTS, measured on a live node. The summary was written at INFO on EVERY imported
|
* <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
|
* 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,
|
* 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 a log
|
* |eligible|=0, no-eligible-signers}. A line that cannot change carries no information, and seven
|
||||||
* that repeats one at that rate is a log an operator stops reading, which is how a real error
|
* validators had just come out of a disk emergency.
|
||||||
* gets missed.
|
|
||||||
*
|
*
|
||||||
* <p>WHAT IS KEPT. Every CHANGE of outcome still logs at INFO immediately, so an operator sees the
|
* <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
|
* transition into and out of quorum on the block it happens. Unchanged state logs once per
|
||||||
@ -182,14 +195,14 @@ public class FalconSealValidationRule implements AttachedBlockHeaderValidationRu
|
|||||||
* @return true to log at INFO, false to drop to DEBUG
|
* @return true to log at INFO, false to drop to DEBUG
|
||||||
*/
|
*/
|
||||||
boolean shouldLogAtInfo(final String rezumat, final long blockNumber) {
|
boolean shouldLogAtInfo(final String rezumat, final long blockNumber) {
|
||||||
final boolean seSchimba = !rezumat.equals(lastLoggedOutcome);
|
final boolean hasChanged = !rezumat.equals(lastLoggedOutcome);
|
||||||
// Long.MIN_VALUE as "never logged" cannot be subtracted from without overflowing, and an
|
// 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
|
// 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,
|
// branch and the very first line, the one that tells an operator the rule is alive at all,
|
||||||
// would never appear.
|
// would never appear.
|
||||||
final boolean bataieDeInima =
|
final boolean bataieDeInima =
|
||||||
lastLoggedBlock == Long.MIN_VALUE || blockNumber - lastLoggedBlock >= LOG_HEARTBEAT_BLOCKS;
|
lastLoggedBlock == Long.MIN_VALUE || blockNumber - lastLoggedBlock >= LOG_HEARTBEAT_BLOCKS;
|
||||||
if (seSchimba || bataieDeInima) {
|
if (hasChanged || bataieDeInima) {
|
||||||
lastLoggedOutcome = rezumat;
|
lastLoggedOutcome = rezumat;
|
||||||
lastLoggedBlock = blockNumber;
|
lastLoggedBlock = blockNumber;
|
||||||
return true;
|
return true;
|
||||||
@ -232,7 +245,7 @@ public class FalconSealValidationRule implements AttachedBlockHeaderValidationRu
|
|||||||
// BLOCKING was simply never armed and the whole PQC layer degraded to LOG-ONLY - it failed OPEN
|
// 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
|
// 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
|
// block, while PENDING keeps the deliberate log-only behaviour that lets a legitimate anchor
|
||||||
// transaction still land (see the ARMING PRECONDITION note below).
|
// transaction still land (see AUD-CONSENSUS-4 below).
|
||||||
final long forkBlock = pqc.forkBlock();
|
final long forkBlock = pqc.forkBlock();
|
||||||
if (header.getNumber() >= forkBlock && pqc.lateAnchorFailed()) {
|
if (header.getNumber() >= forkBlock && pqc.lateAnchorFailed()) {
|
||||||
LOG.error(
|
LOG.error(
|
||||||
@ -246,8 +259,8 @@ public class FalconSealValidationRule implements AttachedBlockHeaderValidationRu
|
|||||||
pqc.anchorAddress());
|
pqc.anchorAddress());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// ARMING PRECONDITION: entering BLOCKING mode requires BOTH the fork height AND an ACTIVE
|
// AUD-CONSENSUS-4: entering BLOCKING mode requires BOTH the fork height AND an ACTIVE anchored
|
||||||
// anchored registry (genesis-anchored, or a late anchor already activated by tryActivateLateAnchor
|
// 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
|
// 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
|
// 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
|
// registry => no eligible seal) and permanently HALTS the chain before the anchor-deploy
|
||||||
@ -258,12 +271,12 @@ public class FalconSealValidationRule implements AttachedBlockHeaderValidationRu
|
|||||||
final boolean forkReached = header.getNumber() >= forkBlock;
|
final boolean forkReached = header.getNumber() >= forkBlock;
|
||||||
final boolean registryActive = pqc.genesisAnchored() || pqc.lateAnchored();
|
final boolean registryActive = pqc.genesisAnchored() || pqc.lateAnchored();
|
||||||
final boolean blocking = forkReached && registryActive;
|
final boolean blocking = forkReached && registryActive;
|
||||||
// ARMED WITHOUT AN ACTIVE REGISTRY: leave a MARK, not only a line. The log-only answer below is
|
// AERE D-079: leave a MARK, not only a line. The log-only answer below is the right answer for a
|
||||||
// the right answer for a header rule, and it is also how this condition used to vanish: the node
|
// header rule, and it is also how this condition used to vanish: the node was configured to
|
||||||
// was configured to enforce a post-quantum quorum, enforced nothing, and said so once per block
|
// enforce a post-quantum quorum, enforced nothing, and said so once per block into a file. The
|
||||||
// into a file. The counter is readable from a test and from a JMX/diagnostic path; the WARN is
|
// counter is readable from a test and from a JMX/diagnostic path; the WARN is emitted only on
|
||||||
// emitted only on the first occurrence, because one line per block at a sub-second block period
|
// the first occurrence, because one line per block at a sub-second block period is itself a
|
||||||
// is itself a hazard.
|
// hazard on this fleet.
|
||||||
if (forkReached
|
if (forkReached
|
||||||
&& !registryActive
|
&& !registryActive
|
||||||
&& pqc.noteBlockingArmedWithoutActiveRegistry(header.getNumber())) {
|
&& pqc.noteBlockingArmedWithoutActiveRegistry(header.getNumber())) {
|
||||||
@ -295,7 +308,7 @@ public class FalconSealValidationRule implements AttachedBlockHeaderValidationRu
|
|||||||
// The gate refuses to attach until the anchored registry covers ALL of these addresses.
|
// The gate refuses to attach until the anchored registry covers ALL of these addresses.
|
||||||
pqc.observeValidators(header.getNumber(), validators);
|
pqc.observeValidators(header.getNumber(), validators);
|
||||||
|
|
||||||
// AERE audit fix, ELIGIBLE-SIGNER BINDING: the eligible-signer set is the intersection of the
|
// AERE audit fix (AUD-CONSENSUS-1 / -2): the eligible-signer set is the intersection of the
|
||||||
// CURRENT validator set with the address-bound signer registry. BOTH the quorum and 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.
|
// counted-seal set are derived from this ONE set, so neither can drift from the other.
|
||||||
final Set<Address> registered = pqc.registeredValidatorAddresses();
|
final Set<Address> registered = pqc.registeredValidatorAddresses();
|
||||||
@ -314,18 +327,17 @@ public class FalconSealValidationRule implements AttachedBlockHeaderValidationRu
|
|||||||
// A seal counts only if its registry-bound address is an eligible signer (i.e. a current
|
// 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
|
// validator with a registered key); seals from registered-but-removed validators, or from
|
||||||
// unregistered indices, are excluded.
|
// unregistered indices, are excluded.
|
||||||
// REGISTRY HEIGHT BINDING (2026-08-06): resolve the key set AT THE HEIGHT OF THE HEADER
|
// D2 (2026-08-06): resolve the key set AT THE HEIGHT OF THE HEADER CARRYING THE SEAL, not at
|
||||||
// CARRYING THE SEAL, not at this node's head. These seals are over THIS header's committed-seal
|
// this node's head. These seals are over THIS header's committed-seal hash, so the height is
|
||||||
// hash, so the height is this header's own - unlike R2, whose certificate commits to the
|
// this header's own - unlike R2, whose certificate commits to the PARENT. The adversarial
|
||||||
// PARENT. The adversarial review measured this rule asking a height-less registry; below the
|
// review measured this rule asking a height-less registry; below the arming height the
|
||||||
// arming height the resolver still answers from the head registry, so the 11.8 million blocks
|
// resolver still answers from the head registry, so the 11.8 million blocks already on chain
|
||||||
// already on chain 2800 are checked exactly as before, but the rule can no longer be the
|
// 2800 are checked exactly as before, but the rule can no longer be the reason a rotation
|
||||||
// reason a rotation makes history unverifiable.
|
// makes history unverifiable.
|
||||||
final Set<Address> counted = new HashSet<>();
|
final Set<Address> counted = new HashSet<>();
|
||||||
for (final FalconSeal seal : falconSeals) {
|
for (final FalconSeal seal : falconSeals) {
|
||||||
// REGISTRY HEIGHT BINDING, the HISTORY door: R1's seals are over THIS header's
|
// D2 (b-v2): the HISTORY door. R1's seals are over THIS header's committed-seal hash,
|
||||||
// committed-seal hash, so the height is the header's own; the header still came from
|
// so the height is the header's own; the header still came from outside.
|
||||||
// outside.
|
|
||||||
final Address signer =
|
final Address signer =
|
||||||
pqc.addressForIndexAtHistoric(header.getNumber(), seal.getValidatorIndex());
|
pqc.addressForIndexAtHistoric(header.getNumber(), seal.getValidatorIndex());
|
||||||
if (signer == null || !eligible.contains(signer) || counted.contains(signer)) {
|
if (signer == null || !eligible.contains(signer) || counted.contains(signer)) {
|
||||||
@ -374,7 +386,7 @@ public class FalconSealValidationRule implements AttachedBlockHeaderValidationRu
|
|||||||
+ "(|eligible|={} < N={}). Running on the eligible intersection (LIVE, quorum={}), "
|
+ "(|eligible|={} < N={}). Running on the eligible intersection (LIVE, quorum={}), "
|
||||||
+ "but two-fault liveness margin is reduced. A validator was added without an "
|
+ "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 "
|
+ "atomic registry re-anchor: RE-ANCHOR the Falcon manifest for the full validator "
|
||||||
+ "set, in the same change that adds the validator, never as a later step.",
|
+ "set (see PQ-CONSENSUS-LIVE-READINESS validator-expansion procedure).",
|
||||||
header.getNumber(),
|
header.getNumber(),
|
||||||
eligible.size(),
|
eligible.size(),
|
||||||
validators.size(),
|
validators.size(),
|
||||||
@ -406,15 +418,15 @@ public class FalconSealValidationRule implements AttachedBlockHeaderValidationRu
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Pre-fork: log-only, never blocks.
|
// Pre-fork: log-only, never blocks.
|
||||||
final String stare =
|
final String state =
|
||||||
eligible.isEmpty()
|
eligible.isEmpty()
|
||||||
? "no-eligible-signers"
|
? "no-eligible-signers"
|
||||||
: (valid >= quorum ? "PQC-QUORUM-MET" : "PQC-quorum-not-yet");
|
: (valid >= quorum ? "PQC-QUORUM-MET" : "PQC-quorum-not-yet");
|
||||||
// The verdict is already decided above. Everything below only picks a LOG LEVEL.
|
// The verdict is already decided above. Everything below only picks a LOG LEVEL.
|
||||||
final String rezumat =
|
final String rezumat =
|
||||||
stare + "|" + valid + "|" + falconSeals.size() + "|" + eligible.size() + "|" + validators.size();
|
state + "|" + valid + "|" + falconSeals.size() + "|" + eligible.size() + "|" + validators.size();
|
||||||
final boolean laInfo = shouldLogAtInfo(rezumat, header.getNumber());
|
final boolean laInfo = shouldLogAtInfo(rezumat, header.getNumber());
|
||||||
final String mesaj =
|
final String message =
|
||||||
"AERE PQC (LOG-ONLY): block {} -> {} of {} Falcon seal(s) verified over |eligible|={} "
|
"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; "
|
+ "(N={}); 2/3 eligible quorum would be {} [{}]. This check never blocks pre-fork; "
|
||||||
+ "ECDSA committed seals remain decisive.";
|
+ "ECDSA committed seals remain decisive.";
|
||||||
@ -422,24 +434,24 @@ public class FalconSealValidationRule implements AttachedBlockHeaderValidationRu
|
|||||||
lastLoggedOutcome = rezumat;
|
lastLoggedOutcome = rezumat;
|
||||||
lastLoggedBlock = header.getNumber();
|
lastLoggedBlock = header.getNumber();
|
||||||
LOG.info(
|
LOG.info(
|
||||||
mesaj,
|
message,
|
||||||
header.getNumber(),
|
header.getNumber(),
|
||||||
valid,
|
valid,
|
||||||
falconSeals.size(),
|
falconSeals.size(),
|
||||||
eligible.size(),
|
eligible.size(),
|
||||||
validators.size(),
|
validators.size(),
|
||||||
quorum,
|
quorum,
|
||||||
stare);
|
state);
|
||||||
} else {
|
} else {
|
||||||
LOG.debug(
|
LOG.debug(
|
||||||
mesaj,
|
message,
|
||||||
header.getNumber(),
|
header.getNumber(),
|
||||||
valid,
|
valid,
|
||||||
falconSeals.size(),
|
falconSeals.size(),
|
||||||
eligible.size(),
|
eligible.size(),
|
||||||
validators.size(),
|
validators.size(),
|
||||||
quorum,
|
quorum,
|
||||||
stare);
|
state);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
} catch (final Exception e) {
|
} 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
|
* 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
|
* 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