diff --git a/anchor/MANIFEST-sha256.txt b/anchor/MANIFEST-sha256.txt new file mode 100644 index 0000000..4093c4c --- /dev/null +++ b/anchor/MANIFEST-sha256.txt @@ -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 diff --git a/anchor/README.md b/anchor/README.md index f8323e6..2783810 100644 --- a/anchor/README.md +++ b/anchor/README.md @@ -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" (IACR 2019/1440; AFT 2020, pp. 189-201). Two things should be said plainly about that citation, because we checked it rather than repeated it. **Winkle does not mention quantum adversaries at -all**: it treats 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 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 @@ -122,6 +123,45 @@ now pinned: change one byte of it and the digest no longer matches, and the head ## What is in here +Updated 2026-08-29. The first publication of this directory, on 2026-08-11, was staged by hand and +was never refreshed: by 29 August, 63 of the 78 files here had changed in the tree we actually run +and 50 files had never been published at all. That is fixed at the root rather than by one copy — +`stage-anchor.cjs` in our repository derives this directory from the authoritative overlay, refuses +to run if the two files this README tells you to read first are missing, deletes what we removed +rather than letting the published set grow monotonically, and stops on the secret-scanner's verdict +instead of around it. The per-file SHA-256 list is in `MANIFEST-sha256.txt`. + +**Three of the files in the overlay are not here, and it is the same three every time**: the +negative-control harnesses. They plant a defect on purpose to prove a guard can fail, so they are a +recipe for disabling a guard rather than a description of one. Everything they prove is stated in +"What is proven, and by what" below, and every guard they exercise is here. + +### What is armed on chain 2800, and what is only present + +This matters more than the file list, so it is stated before it. + +| Layer | In this directory | Armed on chain 2800 | +|---|---|---| +| Anchor certificate under the block hash | yes | **yes**, since block 13,014,000 | +| Enforced minimum seals at an anchor height | yes | **yes**, 6 of 9 since 14,961,456 | +| Legacy per-block Falcon rule (`aere.falcon.forkBlock`) | yes | **no** — retired at the anchor block | +| Post-quantum seal on PREPARE, emission | yes | **no** — no node sets the property | +| Post-quantum seal on PREPARE, enforcement | yes | **no** — no node sets the property | +| Hybrid Falcon + SLH-DSA certificate | yes | **no** — needs new keys, not generated | + +Everything in the "no" rows is **disarmed by absence, not by a flag**: the properties that arm them +are unset, and unset means never. Each refuses loudly on a value it cannot parse rather than booting +a node that believes itself armed — a node that disarms itself because of a mistyped character looks +exactly like a correctly configured one, right up to the day it matters. The tests for that +behaviour are in this directory and they are the ones to read if you doubt the claim. + +The PREPARE layer is newer than the anchor and stronger where it applies: an armed node that refuses +unsealed PREPAREs never reaches the prepared state, so it never sends COMMIT at all. That also means +it has no safety net during an activation, which is why it is not armed anywhere and why its +activation height is a decision that has not been taken. + +### The files + - `consensus/common/.../bft/` — the anchor itself: configuration, the digest, the seal cache and store, the producer that attaches seals, the Falcon registry that maps a validator to a key. - `consensus/qbft/.../headervalidationrules/` — the validation rules: the digest must match, the @@ -173,6 +213,14 @@ and the validation rules. upgraded every node. - **Nothing here demonstrates what is configured on any live network.** These files show what the code does when armed. They are not evidence about any running fleet, and should not be read as any. +- **The PREPARE layer has not run on a live network.** It has been exercised on a test network, + including a mixed run against a second, independent client implementation, and it has not been + armed on chain 2800 or anywhere else that carries value. Test-network evidence is evidence about a + test network. +- **The hybrid Falcon + SLH-DSA certificate has never been signed with a real key.** The scheme + layer is here and a second algorithm passes through the same consensus code untouched, which is + what the tests measure. Generating hybrid validator keys is a separate decision that has not been + taken, so no hybrid certificate exists on any chain. ## One claim we retracted, on purpose diff --git a/anchor/app/src/main/java/org/hyperledger/besu/cli/options/AerePqEmergencyOptions.java b/anchor/app/src/main/java/org/hyperledger/besu/cli/options/AerePqEmergencyOptions.java index 0d68534..509c81f 100644 --- a/anchor/app/src/main/java/org/hyperledger/besu/cli/options/AerePqEmergencyOptions.java +++ b/anchor/app/src/main/java/org/hyperledger/besu/cli/options/AerePqEmergencyOptions.java @@ -1,5 +1,5 @@ /* - * Copyright contributors to Besu / Aere Network. + * Copyright contributors to Besu / AERE Network. * * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at @@ -32,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 * the page has to be able to stand it down with a RESTART. Until this class existed the controls * were real but reachable only as system properties and environment variables, which in practice - * means editing a service unit or a wrapper script on every node of the fleet under time pressure, - * in a file whose syntax nobody remembers, with no {@code --help} to check against. Two of these - * options were already named in the javadoc of {@code PqAnchorConfig} as though they existed. They - * did not. + * means editing a systemd unit or a wrapper script on seven machines under time pressure, in a file + * whose syntax nobody remembers, with no {@code --help} to check against. Two of these options were + * already named in the javadoc of {@code PqAnchorConfig} as though they existed. They did not. * *

The three controls, and why exactly these three. * @@ -49,10 +48,10 @@ import picocli.CommandLine; * one to reach for when the chain has stalled because proposers cannot gather K seals - too * many validators down, a key rotation half-done - and the rest of the scheme is fine. *

  • {@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 BINDING guard, which is a CONFIGURATION guard: one wrong byte in a registry file - * pushed to the fleet stops every node it reached, for a reason that has nothing to do with - * whether the blocks are valid. + * registry that does not satisfy what genesis requires. This is the way back from the A8 + * guard, which is a CONFIGURATION guard: one wrong byte in a registry file pushed to the fleet + * stops every node it reached, for a reason that has nothing to do with whether the blocks are + * valid. * * *

    Every one of them shouts. A quiet way out is worse than no way out, because it will be @@ -65,10 +64,10 @@ import picocli.CommandLine; *

    How they take effect, and why through the properties. Each option writes the SAME system * property the control has always read, before anything reads it. That is deliberate: it leaves * exactly one place where each decision is made, so the command line cannot mean something subtly - * different from the environment variable, and the code that was measured under the registry - * binding work and under the anchor work is the code still doing the deciding. Precedence is - * command line, then system property, then environment variable; which source won is written into - * the log so an operator never has to guess whether the flag took. + * different from the environment variable, and the code that was measured under A8 and under the + * anchor work is the code still doing the deciding. Precedence is command line, then system + * property, then environment variable; which source won is written into the log so an operator never + * has to guess whether the flag took. * *

    Deliberately LOCAL, not on-chain. A halted chain cannot deliver a height-scheduled * configuration change. The only control that works when the chain is ALREADY STOPPED is one that diff --git a/anchor/app/src/main/java/org/hyperledger/besu/controller/QbftBesuControllerBuilder.java b/anchor/app/src/main/java/org/hyperledger/besu/controller/QbftBesuControllerBuilder.java index c245c06..117f2f7 100644 --- a/anchor/app/src/main/java/org/hyperledger/besu/controller/QbftBesuControllerBuilder.java +++ b/anchor/app/src/main/java/org/hyperledger/besu/controller/QbftBesuControllerBuilder.java @@ -264,7 +264,8 @@ public class QbftBesuControllerBuilder extends BesuControllerBuilder { // eligible Falcon seal(s) ... threshold is 3", over "Attachment stays OFF (fail-safe)". // With K=0 the chain heals itself. With K>0 it NEVER heals. // - // A simultaneous restart of the 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. // // 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 // 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 // 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 - // BOOTED WITH, not from a genesis file re-opened by path from a system property. Re-reading a - // file would have reproduced the defect one level up: the enforced binding would again depend - // on a local file a node can be pointed at wrongly, and a node reading a stale copy would - // enforce a stale schedule, or none, in silence. Read from GenesisConfigOptions there is no - // second file: the value enforced comes out of the same object that produced this node's - // genesis hash, so a node that disagrees about the schedule already disagrees about the chain. + // AERE A8 (2026-08-02): the SCHEDULE comes from the genesis configuration BESU BOOTED WITH, not + // from a genesis file re-opened by path from a system property. Re-reading a file would have + // reproduced the defect one level up: the enforced binding would again depend on a local file a + // node can be pointed at wrongly, and a node reading a stale copy would enforce a stale + // schedule, or none, in silence. Read from GenesisConfigOptions there is no second file: the + // value enforced comes out of the same object that produced this node's genesis hash, so a node + // that disagrees about the schedule already disagrees about the chain. // // 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 @@ -378,13 +379,12 @@ public class QbftBesuControllerBuilder extends BesuControllerBuilder { // with zero seals, nobody reached K, nobody could propose, and so nobody sent another Commit. // The same circular deadlock, one level down. // - // WHY THIS IS NOT A REGISTRY-TRUSTED-FROM-A-FILE IN NEW CLOTHES, and this is the whole security - // argument: the seal is SELF-VERIFYING. Every seal read from the file is cryptographically - // verified again against the anchored registry, over an M rebuilt from the head header this very - // process has just loaded, exactly as the producer does at selection time. A forged file cannot - // inject a seal without forging a Falcon-512 signature; all it can obtain is the empty cache an - // absent file already gives. The defect back then was a REGISTRY of keys trusted because it sat - // in a file. + // WHY THIS IS NOT A8 IN NEW CLOTHES, and this is the whole security argument: the seal is + // SELF-VERIFYING. Every seal read from the file is cryptographically verified again against the + // anchored registry, over an M rebuilt from the head header this very process has just loaded, + // exactly as the producer does at selection time. A forged file cannot inject a seal without + // forging a Falcon-512 signature; all it can obtain is the empty cache an absent file already + // gives. A8 was a REGISTRY of keys trusted because it sat in a file. // // Deliberately here: the registry is already armed by the block above (otherwise no seal could // resolve and the restore would have gone quiet for nothing), the chain head exists, and the @@ -684,8 +684,8 @@ public class QbftBesuControllerBuilder extends BesuControllerBuilder { } /** - * AERE REGISTRY-BINDING: read a genesis {@code config.*} value that Besu itself does not model, - * out of the genesis configuration THIS NODE BOOTED WITH. + * AERE A8: read a genesis {@code config.*} value that Besu itself does not model, out of the + * genesis configuration THIS NODE BOOTED WITH. * *

    Besu's {@code GenesisConfigOptions.asMap()} cannot be used for this: it is an allow-list of * the keys Besu knows about, so a key of ours is simply absent from it and the guard would read @@ -705,10 +705,9 @@ public class QbftBesuControllerBuilder extends BesuControllerBuilder { private com.fasterxml.jackson.databind.JsonNode aereGenesisConfigNode(final String key) { if (!(genesisConfigOptions instanceof JsonGenesisConfigOptions)) { LOG.warn( - "AERE PQC REGISTRY-BINDING: the genesis configuration is a {}, not the JSON-backed " - + "implementation, so config.{} cannot be read and the Falcon registry binding is " - + "NOT ENFORCED on this node. A binding everybody believes is on and is not is worse " - + "than no binding.", + "AERE PQC A8: the genesis configuration is a {}, not the JSON-backed implementation, so " + + "config.{} cannot be read and the Falcon registry binding is NOT ENFORCED on this " + + "node. A binding everybody believes is on and is not is worse than no binding.", genesisConfigOptions.getClass().getName(), key); return null; diff --git a/anchor/app/src/test/java/org/hyperledger/besu/cli/options/AerePqEmergencyOptionsTest.java b/anchor/app/src/test/java/org/hyperledger/besu/cli/options/AerePqEmergencyOptionsTest.java index e74459f..b77a549 100644 --- a/anchor/app/src/test/java/org/hyperledger/besu/cli/options/AerePqEmergencyOptionsTest.java +++ b/anchor/app/src/test/java/org/hyperledger/besu/cli/options/AerePqEmergencyOptionsTest.java @@ -1,5 +1,5 @@ /* - * Copyright contributors to Besu / Aere Network. + * Copyright contributors to Besu / AERE Network. * * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at diff --git a/anchor/config/src/main/java/org/hyperledger/besu/config/JsonGenesisConfigOptions.java b/anchor/config/src/main/java/org/hyperledger/besu/config/JsonGenesisConfigOptions.java index e0d6a60..7958160 100644 --- a/anchor/config/src/main/java/org/hyperledger/besu/config/JsonGenesisConfigOptions.java +++ b/anchor/config/src/main/java/org/hyperledger/besu/config/JsonGenesisConfigOptions.java @@ -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 - * null. + * AERE A8: the raw genesis {@code config.*} value for a key Besu does not model, or null. * *

    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 diff --git a/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/FalconSeal.java b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/FalconSeal.java index 578b6f5..9f290a1 100644 --- a/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/FalconSeal.java +++ b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/FalconSeal.java @@ -1,5 +1,5 @@ /* - * Copyright contributors to Besu / Aere Network. + * Copyright contributors to Besu / AERE Network. * * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at diff --git a/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/FalconSealScheme.java b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/FalconSealScheme.java new file mode 100644 index 0000000..0f0090c --- /dev/null +++ b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/FalconSealScheme.java @@ -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 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 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 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()); + } +} diff --git a/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/FalconSealSupport.java b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/FalconSealSupport.java index af694dc..6456c42 100644 --- a/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/FalconSealSupport.java +++ b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/FalconSealSupport.java @@ -57,8 +57,9 @@ import org.slf4j.LoggerFactory; *

    NAMING, and this is not pedantry. This line used to read "for the hybrid post-quantum * consensus seal". AERE DOES NOT HAVE POST-QUANTUM CONSENSUS and must never be described as having * it: proposer selection and finality are classical secp256k1 ECDSA QBFT, and the post-quantum - * layer is signature, precompile and account level. Our audit scope tells reviewers to FLAG that - * phrase wherever it appears in code comments. It appeared here. + * layer is signature, precompile and account level. Our own audit scope dossier + * ({@code audit-package-pq-consensus/scope/AUDIT-SCOPE-DOSSIER-2026-07-12.md}, section 3) tells + * reviewers to FLAG that phrase wherever it appears in code comments. It appeared here. * *

    This deliberately does NOT ride the shared secp256k1 {@code NodeKey} / {@code SecurityModule} * singleton (that interface returns ECDSA R,S and is used chain-wide for transactions and devp2p). @@ -111,7 +112,7 @@ import org.slf4j.LoggerFactory; * whenever {@link #addressForIndex} returns null or {@link #verify} returns false. A node whose * registry file fails to load therefore rejects every header carrying a certificate, i.e. it halts * itself, and two nodes with DIFFERENT registry files disagree about which headers are valid. That - * is the unbound-registry defect, and it is why the registry has to be bound to genesis by {@code pqRegistryHash} + * is defect A8 and it is why the registry has to be bound to genesis by {@code pqRegistryHash} * before any of this is armed. While both of those gates are unset - which is the state of chain * 2800 today - the subsystem is log-only and the old sentence holds; it is not a property of this * file, it is a property of the configuration. @@ -135,6 +136,10 @@ public final class FalconSealSupport { private static final String ANCHOR_SLOT = "0000000000000000000000000000000000000000000000000000000000000000"; + /** Cate sigilii a emis acest nod pe PREPARE. Vezi {@link #preparesSealed()}. */ + private final java.util.concurrent.atomic.AtomicLong preparesSealed = + new java.util.concurrent.atomic.AtomicLong(); + private final boolean signingEnabled; private final int localIndex; private final FalconPrivateKeyParameters localPrivateKey; @@ -150,7 +155,7 @@ public final class FalconSealSupport { private final boolean genesisAnchored; /** - * AERE GENESIS BINDING: the registry file this node ACTUALLY used, and which of the three sources it came from. + * AERE A8: the registry file this node ACTUALLY used, and which of the three sources it came from. * Recorded so the consensus-binding guard can re-read exactly that file and hash it, rather than * hashing something adjacent to it. Null when no registry is configured at all. */ @@ -158,11 +163,11 @@ public final class FalconSealSupport { private final PqRegistryHash.SourceKind registrySourceKind; - /** Outcome of the genesis-binding guard; NOT_CHECKED equivalent is null until it has run. */ + /** Outcome of the A8 registry-binding guard; NOT_CHECKED equivalent is null until it has run. */ private volatile PqRegistryHash.GateState registryBindingState; /** - * AERE GENESIS BINDING (per-block half): the schedule the startup guard actually enforced, the registry object + * AERE A8 (per-block half): the schedule the startup guard actually enforced, the registry object * it hashed, and the chain id it hashed under. Held so that {@link #registryBindingSatisfiedAt} * can answer the SAME question on the per-block consensus path without re-reading a file 61 * million times a year, and without a second, drifting copy of the decision. @@ -176,17 +181,17 @@ public final class FalconSealSupport { private volatile PqRegistryHash.Registry registryBindingLoaded; /** - * HEIGHT SCHEDULE: every registry this node holds, bound to the schedule entry each one satisfies. + * D-081: every registry this node holds, bound to the schedule entry each one satisfies. * *

    {@link #registryBindingLoaded} above is the registry for the HEAD, and it is what the node * signs with. It is kept because every diagnostic message names it. This field is the whole * scheduled history, and it is what the per-block binding question and height-resolved * verification are answered from. With no history configured the set holds exactly the one - * registry above, so both answers are bit-for-bit what they were before the height schedule existed. + * registry above, so both answers are bit-for-bit what they were before D-081. */ private volatile PqRegistryHash.RegistrySet registryBindingSet; - /** HEIGHT SCHEDULE: per (schedule entry, index) Falcon public keys, built on demand from the set. */ + /** D-081: per (schedule entry, index) Falcon public keys, built on demand from the set. */ private final Map historicalKeys = new ConcurrentHashMap<>(); private volatile long registryBindingChainId; @@ -199,7 +204,7 @@ public final class FalconSealSupport { * AERE OPTIUNI-URGENTA (2026-08-02): the operator has EXPLICITLY asked this node to run even * though its Falcon registry does not satisfy the binding genesis requires. * - *

    WHY THIS EXISTS AT ALL. The genesis-binding guard is a CONFIGURATION guard: it refuses to start a node + *

    WHY THIS EXISTS AT ALL. The A8 guard is a CONFIGURATION guard: it refuses to start a node * whose registry file is not the one the chain names. That refusal is correct and it is * fail-closed, and it also means a single bad registry file pushed to the fleet takes the fleet * down and the only documented way back was to rebuild or to hand-edit a service unit at whatever @@ -289,8 +294,8 @@ public final class FalconSealSupport { * chain head at startup. Sized from the measured startup-to-first-commit window (45.2 s, i.e. ~46 * blocks on the 1 s scratch fleet) with a wide safety factor, so that a node which is still * opening its database, syncing and joining the network cannot have the activation height arrive - * underneath it. Chains with a sub-second block period must RAISE this in proportion: the - * margin has to cover the same wall-clock startup window at the faster block rate. + * underneath it. Chains with a sub-second block period must RAISE this: see + * GARDA-ACTIVARE-2026-08-01.md for the sizing formula. */ private static final long DEFAULT_MIN_ATTACH_FUTURE_MARGIN = 1024L; @@ -311,7 +316,7 @@ public final class FalconSealSupport { private static final String ADMISSION_RECEIPT_FILE = "aere-falcon-activation.receipt"; /** - * HEIGHT SCHEDULE: the registries this node holds for SCHEDULED HEIGHTS IT IS NO LONGER AT, as a + * D-081: the registries this node holds for SCHEDULED HEIGHTS IT IS NO LONGER AT, as a * comma-separated list of files. Env: {@code AERE_FALCON_REGISTRY_HISTORY}. * *

    WHY A SECOND PROPERTY AND NOT A LIST IN THE FIRST. {@code aere.falcon.registry} names the @@ -338,7 +343,7 @@ public final class FalconSealSupport { public static final String ENV_REGISTRY_MISMATCH_ALLOW = "AERE_PQ_REGISTRY_MISMATCH_ALLOW"; /** - * AERE LATE-ANCHOR HEIGHT: the DECLARED height at or after which the on-chain late-anchor registry contract is + * AERE D-079: the DECLARED height at or after which the on-chain late-anchor registry contract is * expected to be observable. Mandatory whenever a blocking fork height is armed over a late-anchor * registry that is still pending; see {@link #validateAnchorObservationHeightOrAbort}. */ @@ -351,7 +356,7 @@ public final class FalconSealSupport { private final long attachBlock; /** - * AERE LATE-ANCHOR HEIGHT: the BLOCKING height, resolved and validated exactly ONCE, at construction, and + * AERE D-079: the BLOCKING height, resolved and validated exactly ONCE, at construction, and * owned thereafter. Long.MAX_VALUE means never blocking. * *

    It used to be a property read on every call to {@link #forkBlock()}, with a @@ -364,7 +369,7 @@ public final class FalconSealSupport { private final long forkBlock; /** - * AERE LATE-ANCHOR HEIGHT: the height at and after which the on-chain LATE-ANCHOR registry contract is expected + * AERE D-079: the height at and after which the on-chain LATE-ANCHOR registry contract is expected * to be observable, as DECLARED by the operator. Long.MAX_VALUE when undeclared. * *

    This value does not activate anything. It exists because without it the ordering that the @@ -376,7 +381,7 @@ public final class FalconSealSupport { private final long anchorObserveBlock; /** - * AERE LATE-ANCHOR HEIGHT: first height at which this node validated a header at or after the blocking height + * AERE D-079: first height at which this node validated a header at or after the blocking height * while the anchored registry was NOT active, or -1 if that has never happened. * *

    This is the residual the configuration guard cannot close: an operator may declare the @@ -407,13 +412,13 @@ public final class FalconSealSupport { private final AtomicBoolean loggedCoverageBlocked = new AtomicBoolean(false); /** - * LOOKUP HARDENING (b): the once-only latch for the refusal to resolve keys at an armed height with no + * D2 HARDENING (b): the once-only latch for the refusal to resolve keys at an armed height with no * height-to-registry binding. One line per block at a 523 ms period is a hazard, not a diagnostic. */ private final AtomicBoolean loggedUnboundArmedHeight = new AtomicBoolean(false); /** - * AERE COVERAGE MARGIN (2026-08-02): the last coverage situation this node reported, as "N/R", so the + * AERE D-078 (2026-08-02): the last coverage situation this node reported, as "N/R", so the * coverage line is emitted exactly when the situation CHANGES and not once per block. Null means * nothing has been reported yet. */ @@ -467,7 +472,7 @@ public final class FalconSealSupport { manifestPath); } } else if (registryPath != null) { - // AERE GENESIS BINDING REPAIR (2026-08-02): the properties source the design prescribed was measured to + // AERE A8 REPAIR (2026-08-02): the properties source the design prescribed was measured to // HALT EVERY NODE at the activation height. Not by being mutable, and not by disagreeing with // a hash: by carrying public keys and NO ADDRESSES. addressForIndex() then returns null for // every index, and the V2 seals rule (R2) refuses every header that carries a certificate. @@ -515,13 +520,13 @@ public final class FalconSealSupport { } // Address binding is ALL-OR-NOTHING. A half-bound registry is worse than an unbound one: // some indices would resolve and some would not, so the fleet would reject a header that - // one operator can verify and another cannot, which is the same defect wearing a different hat. + // one operator can verify and another cannot, which is defect A8 wearing a different hat. final boolean fullyBound = !reg.isEmpty() && propAddr.keySet().equals(reg.keySet()); if (fullyBound) { regAddr.putAll(propAddr); } else if (!propAddr.isEmpty()) { LOG.error( - "AERE PQC GENESIS-BINDING: LEGACY registry file {} carries {} '.addr' rows for {} keys. A " + "AERE PQC A8: LEGACY registry file {} carries {} '.addr' rows for {} keys. A " + "PARTIALLY address-bound registry is refused as a binding source (all-or-" + "nothing): some indices would resolve to a validator address and some would " + "not, so two nodes would disagree about which headers are valid. Treating this " @@ -589,7 +594,7 @@ public final class FalconSealSupport { this.pendingLateHash = lateHash; this.anchorContractAddress = anchorAddr; - // AERE GENESIS BINDING: remember WHICH file the registry came from, so the consensus-binding guard hashes + // AERE A8: remember WHICH file the registry came from, so the consensus-binding guard hashes // the file this node is really running on and not a different one that happens to be nearby. if (genesisPath != null) { this.registrySourcePath = genesisPath; @@ -628,7 +633,7 @@ public final class FalconSealSupport { // before anything else. A present-but-unparseable aere.falcon.forkBlock must not silently // degrade to never-blocking (log-only); it aborts node init here (config time, NOT the per-block // consensus path). - // AERE LATE-ANCHOR HEIGHT: the result is TAKEN, not merely checked. Nothing re-reads the property afterwards. + // AERE D-079: the result is TAKEN, not merely checked. Nothing re-reads the property afterwards. this.forkBlock = validateForkBlockConfigOrAbort(); // AERE FIX-OPRIRE-CONSENS (b): resolve and validate the ATTACHMENT gate. Every inconsistent @@ -636,7 +641,7 @@ public final class FalconSealSupport { // surprise mid-chain. this.attachBlock = validateAndResolveAttachBlockOrAbort(this.forkBlock); - // AERE LATE-ANCHOR HEIGHT: the ORDER between the blocking height and the height at which the registry that + // AERE D-079: the ORDER between the blocking height and the height at which the registry that // backs it can become active. Aborts here, at config time, for the same reason as everything // above it: after the node has joined, the same error is a silent degradation to log-only. this.anchorObserveBlock = @@ -650,7 +655,7 @@ public final class FalconSealSupport { // is still empty until activation, which is expected.) armingReadinessDiagnostic(); - // AERE ROW BINDING (2026-08-06): the SECOND arming question, and the one AERE-PQC-REG-ARM-01 cannot + // AERE D-146 (2026-08-06): the SECOND arming question, and the one AERE-PQC-REG-ARM-01 cannot // answer. Address-bound says every index has SOME address next to it. It does not say that the // validator at that address ever held the Falcon key filed under it. Measured on the real // verification path on 2026-08-06: a registry with two rows' public keys swapped - no duplicate @@ -658,7 +663,7 @@ public final class FalconSealSupport { // header, and one key placed at two indices satisfied a threshold of two on its own. requireRegistryBindingProofsOrAbort(); - // AERE COVERAGE MARGIN (2026-08-03): the arm-time comparison the repair above NAMED and did not make. + // AERE D-078 (2026-08-03): the arm-time comparison the repair above NAMED and did not make. // armingReadinessDiagnostic() answers "is the manifest address-bound"; it never asks whether the // threshold the fleet is about to arm is one the fleet can be guaranteed to MEET. validateThresholdReachabilityOrAbort(); @@ -674,7 +679,7 @@ public final class FalconSealSupport { * validation, so it never throws on the consensus path. An UNSET value keeps the safe * never-blocking log-only default. * - *

    AERE LATE-ANCHOR HEIGHT (2026-08-03): it now RETURNS the validated height and the constructor keeps it in + *

    AERE D-079 (2026-08-03): it now RETURNS the validated height and the constructor keeps it in * a final field. Validating a value and then re-reading its source on every use leaves the * original defect intact one level down, which is exactly where it was found. * @@ -712,7 +717,7 @@ public final class FalconSealSupport { } /** - * AERE LATE-ANCHOR HEIGHT: the ordering guard between the BLOCKING height and the height at which the registry + * AERE D-079: the ordering guard between the BLOCKING height and the height at which the registry * that backs it can first be active. * *

    THE HOLE THIS CLOSES, in the words of the code that documented it and did nothing about it. @@ -873,7 +878,7 @@ public final class FalconSealSupport { * {@code aere.falcon.testnetAllowSmallFleet=true}, which logs an ERROR every start. * * - * @param fork the already-validated blocking height (AERE LATE-ANCHOR HEIGHT: passed in rather than re-parsed + * @param fork the already-validated blocking height (AERE D-079: passed in rather than re-parsed * from the property, so this method and {@link #forkBlock()} cannot disagree about it) * @return the resolved attachment height, or {@link Long#MAX_VALUE} when unset */ @@ -1480,9 +1485,8 @@ public final class FalconSealSupport { * AERE_FALCON_ATTACHINTERVAL}. * *

    WHY THIS EXISTS, measured on chain 2800 on 2026-08-08. Attachment was armed on all seven - * validators and the header went from 525 to 3844 bytes, five Falcon seals on EVERY block: more - * than seven times the header bytes, on nodes whose free space could not absorb it. The anchor - * producer + * validators and the header went from 525 to 3844 bytes, five Falcon seals on EVERY block, about + * 200 GB per node per year against 12 GB of free disk on the tightest host. The anchor producer * already has both an interval and a seal cap, but the assembler reached when the anchor is NOT * armed has neither, and that assembler is the one that runs before the activation height. So the * cheap-by-design path was unreachable precisely during the window it was needed. @@ -1665,10 +1669,10 @@ public final class FalconSealSupport { return chainRelativeState; } - // ---- AERE GENESIS BINDING (2026-08-01): BIND THE REGISTRY TO CONSENSUS ---- + // ---- AERE A8 (2026-08-01): BIND THE REGISTRY TO CONSENSUS ---- /** - * GENESIS BINDING GUARD: refuse to start when the Falcon registry this node loaded is not the one the chain + * A8 GUARD: refuse to start when the Falcon registry this node loaded is not the one the chain * requires at this height. * *

    THE DEFECT, read out of the constructor above and not guessed at. The registry that answers @@ -1712,7 +1716,7 @@ public final class FalconSealSupport { final PqRegistryHash.Schedule schedule; if (genesisForSchedule == null) { LOG.warn( - "AERE PQC GENESIS-BINDING: no genesis file is reachable to read config.pqRegistryHash from (neither " + "AERE PQC A8: no genesis file is reachable to read config.pqRegistryHash from (neither " + "aere.pq.genesis nor aere.falcon.genesis is set), so the Falcon registry on this " + "node is NOT bound to consensus. The registry in use is {} ({}). Two nodes holding " + "different registry files would disagree about which public key validator index i " @@ -1728,7 +1732,7 @@ public final class FalconSealSupport { } /** - * AERE GENESIS BINDING, the form the caller should actually use: verify the registry binding against a + * AERE A8, the form the caller should actually use: verify the registry binding against a * schedule that came from the genesis configuration BESU ITSELF PARSED AND BOOTED WITH. * *

    WHY THIS OVERLOAD EXISTS, and it is not tidiness. The single-argument form above reads @@ -1754,11 +1758,11 @@ public final class FalconSealSupport { loaded = PqRegistryHash.loadAuto(Paths.get(registrySourcePath)); } - // HEIGHT SCHEDULE: build the height-resolved set BEFORE anything is published, from the registry this + // D-081: build the height-resolved set BEFORE anything is published, from the registry this // node signs with plus every registry named in the history list. With no history configured the - // set holds exactly the one registry and every answer below is what it was before the height schedule existed. + // set holds exactly the one registry and every answer below is what it was before D-081. // - // AERE HELD-SET SCOPE (2026-08-06). THIS BLOCK USED TO SIT 41 LINES LOWER, AND THAT WAS THE DEFECT. The + // AERE D-A (2026-08-06). THIS BLOCK USED TO SIT 41 LINES LOWER, AND THAT WAS THE DEFECT. The // startup guard below was handed the single primary registry and threw // AERE-PQC-REG-MISMATCH-01 before this code ever ran, so a node holding exactly the right files // - the post-rotation registry as its own, the pre-rotation one as history - refused to start @@ -1799,7 +1803,7 @@ public final class FalconSealSupport { registryOverrideEngaged = true; LOG.error( "AERE PQC EMERGENCY [AERE-PQC-REG-UNSAFE-01]: STARTING ANYWAY WITH AN UNVERIFIED FALCON " - + "REGISTRY. The genesis-binding guard REFUSED this node ({}), and {} is set, so " + + "REGISTRY. The A8 registry-binding guard REFUSED this node ({}), and {} is set, so " + "the refusal has been overridden BY EXPLICIT OPERATOR REQUEST. What that means, " + "stated plainly: from the first height at which genesis requires a registry hash, " + "this node cannot correctly decide whether a header's Falcon certificate is valid, " @@ -1819,7 +1823,7 @@ public final class FalconSealSupport { // refuses at exactly the heights that are uncovered, which is the narrowest fail-closed // action that still names the problem. What must never happen is silence. LOG.error( - "AERE PQC HEIGHT-SCHEDULE: this node holds {} registry file(s) and the chain's pqRegistryHash " + "AERE PQC D-081: this node holds {} registry file(s) and the chain's pqRegistryHash " + "schedule has {} entr(ies), of which the heights {} are covered by NOTHING this " + "node holds. Every header at or after such a height will be REFUSED, and a node " + "syncing from genesis will stop there. Name the missing registry file(s) in {} " @@ -1844,13 +1848,13 @@ public final class FalconSealSupport { } /** - * AERE SIGNED-HEIGHT CHECK (2026-08-06), THE LOCAL HALF. The height this node ARMS at must be a height the fleet + * AERE D-B (2026-08-06), THE LOCAL HALF. The height this node ARMS at must be a height the fleet * actually signed a registry for. * *

    WHY THIS CANNOT BE THE SAME KIND OF GUARD AS THE ONE ABOVE, and the difference is the whole - * honest limitation of that check. {@code aere.pq.anchorBlock} is a SYSTEM PROPERTY, set per node through - * {@code BESU_OPTS}. It is in no genesis. Nothing in the document the seven validators hold - * byte-identically constrains it. So the agreement of seven nodes on an arming height CANNOT BE + * honest limitation of D-B. {@code aere.pq.anchorBlock} is a SYSTEM PROPERTY, set per node through + * {@code BESU_OPTS}. It is in no genesis. Nothing in the document the validators hold + * byte-identically constrains it. So the agreement of the nodes on an arming height CANNOT BE * ENFORCED by any code that runs on one node - it can only be DETECTED locally, and that is what * this does. Enforcement would require the height to move into genesis, which is a change to the * chain's configuration and not to this class. That is written here as a limitation, not as a @@ -1901,7 +1905,7 @@ public final class FalconSealSupport { } throw new PqRegistryHash.RegistryConfigException( "AERE-PQC-REG-BIND-09", - "AERE PQC SIGNED-HEIGHT: REFUSING TO START - this node arms at a height no validator signed for.\n" + "AERE PQC D-B: REFUSING TO START - this node arms at a height no validator signed for.\n" + " FIELD: aere.pq.anchorBlock (system property, per node, read through " + "PqAnchorConfig) versus the 'block' values of config.pqRegistryHash in genesis (" + schedule.source() @@ -1919,30 +1923,30 @@ public final class FalconSealSupport { + "one the fleet signed, the day the chain starts enforcing post-quantum seals is a " + "number one operator typed.\n" + " WHY NOTHING ELSE CATCHES IT: aere.pq.anchorBlock is not in genesis. It is not in " - + "the document all seven nodes hold identically, so no node can hold another node to " + + "the document every node holds identically, so no node can hold another node to " + "it. This refusal is DETECTION on this node only. Two nodes with different " + "aere.pq.anchorBlock still do not disagree about a header until the lower of the two " + "heights, and this guard cannot see the other node's value.\n" - + " WHAT TO DO: EITHER set aere.pq.anchorBlock to one of the heights above, on all " - + "seven nodes and in the same change - a node armed alone validates differently from " + + " WHAT TO DO: EITHER set aere.pq.anchorBlock to one of the heights above, on every " + + "node and in the same change - a node armed alone validates differently from " + "the rest. OR, if the activation day genuinely moved, re-run the key ceremony for the " + "new height, put the re-signed registry's NEW hash in config.pqRegistryHash at that " + "height, and roll it to the whole fleet. Moving the day is 14 signatures. It is " + "supposed to be.\n" - + " CHECK THE WHOLE FLEET BEFORE RESTARTING ANYTHING: this property has to read " - + "the same on every node, and a split state is a fault, " - + "not a warning."); + + " CHECK THE WHOLE FLEET BEFORE RESTARTING ANYTHING: this property is per node and " + + "is in no genesis, so two nodes can disagree about it silently. Compare it across " + + "every validator and treat a split state as a fault, not as a detail."); } /** - * AERE GENESIS BINDING, the PER-BLOCK half: does the registry this node is running on satisfy the binding the + * AERE A8, the PER-BLOCK half: does the registry this node is running on satisfy the binding the * chain requires AT THIS HEIGHT? Never throws. * *

    WHY A STARTUP GUARD IS NOT ENOUGH, measured as a gap and not assumed. The startup guard * answers the question once, against the chain head that existed at startup. A node that is * already running when a ROTATION height in the schedule passes underneath it is never asked * again: it keeps validating with a registry the chain has moved off, and it keeps reporting - * itself healthy while doing it. That is the same failure mode the genesis binding was opened for, arriving by a + * itself healthy while doing it. That is the same failure mode A8 was opened for, arriving by a * different door. With a schedule of one entry the two guards are equivalent; with two or more, * only this one covers the interval after the second entry. * @@ -1966,7 +1970,7 @@ public final class FalconSealSupport { return true; // below the first scheduled height: the 11.8 million existing blocks are untouched } final PqRegistryHash.Registry loaded = this.registryBindingLoaded; - // HEIGHT SCHEDULE: ask the whole scheduled history, not only the entry in force at the head. Before this + // D-081: ask the whole scheduled history, not only the entry in force at the head. Before this // change a node held one registry, so one rotation left NO configuration that satisfied both // the pre-rotation interval and the head, and the chain became permanently unjoinable. final boolean ok = @@ -2029,11 +2033,10 @@ public final class FalconSealSupport { blockNumber, required.hash(), required.block(), - // GENESIS BINDING: this used to print hashV1 next to `required.hash()`, which is computed - // with hashFor. Two numbers set side by side to be compared, but computed differently: for - // a registry that carries proofs the two can NEVER match, and the message sends the - // operator hunting for the defect where it is not. Measured on a fleet of seven on - // 2026-08-06. + // A8: this used to print hashV1 next to `required.hash()`, which is computed with hashFor. + // Two numbers set side by side to be compared, but computed differently: for a registry + // that carries proofs the two can NEVER match, and the message sends the operator hunting + // for the defect where it is not. Measured on a fleet of seven on 2026-08-06. loaded == null ? "(no registry loaded)" : PqRegistryHash.hashFor(loaded, registryBindingChainId), @@ -2058,7 +2061,7 @@ public final class FalconSealSupport { return; } LOG.error( - "AERE PQC GENESIS-BINDING [AERE-PQC-REG-BLOCK-01]: REFUSING header at height {} (fail-closed). The " + "AERE PQC A8 [AERE-PQC-REG-BLOCK-01]: REFUSING header at height {} (fail-closed). The " + "chain requires registry hash {} from height {} (schedule source {}), and the " + "registry this node is running has hash {}. Registry in use: {} ({}), {} entries, " + "address-bound={}. WHY THIS FIRES NOW AND NOT AT STARTUP: this node started under an " @@ -2069,8 +2072,8 @@ public final class FalconSealSupport { required.hash(), required.block(), this.registryBindingSchedule == null ? "(none)" : this.registryBindingSchedule.source(), - // GENESIS BINDING, same reason as above: this is compared against required.hash(), which - // is computed with hashFor. + // A8, same reason as above: this is compared against required.hash(), which is computed + // with hashFor. loaded == null ? "(no registry loaded)" : PqRegistryHash.hashFor(loaded, registryBindingChainId), loaded == null ? "(none)" : loaded.sourcePath(), loaded == null ? "(none)" : loaded.kind(), @@ -2095,7 +2098,7 @@ public final class FalconSealSupport { } /** - * Outcome of the genesis-binding guard. + * Outcome of the A8 registry-binding guard. * * @return the gate state, or null when the guard has not run */ @@ -2312,9 +2315,9 @@ public final class FalconSealSupport { } final Iterator> sit = storage.fields(); while (sit.hasNext()) { - final Map.Entry se = sit.next(); - if (norm(se.getKey(), 64).equals(ANCHOR_SLOT)) { - return norm(se.getValue().asText(), 64); + final Map.Entry entry = sit.next(); + if (norm(entry.getKey(), 64).equals(ANCHOR_SLOT)) { + return norm(entry.getValue().asText(), 64); } } } @@ -2352,14 +2355,59 @@ public final class FalconSealSupport { return instance; } - private static String resolve(final String sysProp, final String envVar) { - final String v = System.getProperty(sysProp); - if (v != null && !v.isBlank()) { - return v; + /** + * D-177 (2026-08-28): every legacy {@code aere.falcon.*} switch now has a first-class + * algorithm-neutral twin, {@code aere.pq.sig.*} (env {@code AERE_PQ_SIG_*}). The protocol + * surface an operator touches must not name a primitive: the founder's next step is the + * Falcon+SPHINCS+ hybrid, and a second algorithm must not mean a second set of sixteen + * parallel switches. Precedence, and why it is shaped this way: + * + *

    + */ + static String resolve(final String sysProp, final String envVar) { + final String neutralProp = + sysProp.startsWith("aere.falcon.") + ? "aere.pq.sig." + sysProp.substring("aere.falcon.".length()) + : null; + final String neutralEnv = + envVar.startsWith("AERE_FALCON_") + ? "AERE_PQ_SIG_" + envVar.substring("AERE_FALCON_".length()) + : null; + final String neutral = firstNonBlank( + neutralProp == null ? null : System.getProperty(neutralProp), + neutralEnv == null ? null : System.getenv(neutralEnv)); + final String legacy = firstNonBlank(System.getProperty(sysProp), System.getenv(envVar)); + if (neutral != null && legacy != null && !neutral.trim().equals(legacy.trim())) { + throw new ActivationConfigException( + ActivationConfigException.Kind.SYNTAX, + "AERE-PQC-CFG-DUAL-NAME-01", + "AERE PQC: " + + neutralProp + + " and its legacy twin " + + sysProp + + " are BOTH set, to DIFFERENT values ('" + + neutral + + "' vs '" + + legacy + + "'). Refusing to start: a node that silently prefers one spelling turns a typo " + + "into a consensus divergence. Set exactly one, or both to the same value."); } - final String e = System.getenv(envVar); - if (e != null && !e.isBlank()) { - return e; + return neutral != null ? neutral : legacy; + } + + private static String firstNonBlank(final String a, final String b) { + if (a != null && !a.isBlank()) { + return a; + } + if (b != null && !b.isBlank()) { + return b; } return null; } @@ -2529,12 +2577,12 @@ public final class FalconSealSupport { *

    Nothing else catches it. Not startup, not the log, not the block rhythm, not rejections, not * the divergence detector - every node agrees, because the certificate is valid. Until today the * only guard was a PROCEDURE step run by hand on each target, and a procedure step can be skipped. - * The written procedure claimed it "shows up as R2 rejections at the anchor height"; measured, it does not. + * The runbook claimed it "shows up as R2 rejections at the anchor height"; measured, it does not. * *

    WHY IT IS SAFE TO REFUSE HERE. Same argument as the other startup guards: this runs before * the network and the QBFT state machine start, so it is a clean refusal to start rather than a * mid-flight halt. It is INERT unless a Falcon key is configured AND the registry is address-bound, - * so wherever no {@code aere.falcon.key} is configured, it cannot fire. + * so on chain 2800 as it stands today - no {@code aere.falcon.key} anywhere - it cannot fire. * *

    A null bound address is NOT treated as a failure: that means a pubkey-only registry, which is * already refused elsewhere for the paths that matter, and turning it into a second refusal here @@ -2600,16 +2648,16 @@ public final class FalconSealSupport { } /** - * LOOKUP HARDENING (a). THIS NODE's own signing identity, which is the one place in the stack that + * D2 HARDENING (a). THIS NODE's own signing identity, which is the one place in the stack that * legitimately has no height. * *

    WHY IT IS A SEPARATE METHOD AND NOT {@code addressForIndex(localIndex())}. The adversarial - * review of 2026-08-02 found the verification path asking the registry questions with no + * review of 2026-08-02 (D2) found the verification path asking the registry questions with no * height. Deleting the height-less pair from {@link PqSignerRegistry} left exactly one honest * caller behind: {@code QbftBlockCreatorAdaptor} asking "am I, right now, an eligible signer", * before signing with the single private key this process holds. There is no historical question * in that, and inventing a height for it would be a lie dressed as rigour. Giving it its own name - * means the two uses can no longer be confused by a future reader, which is how the defect arrived in the + * means the two uses can no longer be confused by a future reader, which is how D2 arrived in the * first place. * * @return this node's registry-bound address, or null when it holds no key or the registry is not @@ -2620,16 +2668,16 @@ public final class FalconSealSupport { } /** - * LOOKUP HARDENING (b). The height at and above which this node is ARMED, i.e. from which a header's + * D2 HARDENING (b). The height at and above which this node is ARMED, i.e. from which a header's * Falcon certificate carries consensus weight. * *

    WHY THE HEIGHT-RESOLVED LOOKUPS NEED IT. Before this, {@code keyAt} and {@code * addressForIndexAt} fell back to the HEAD registry whenever no {@code config.pqRegistryHash} * entry was in force - and {@code pqRegistryHash} is in no genesis this fleet runs (measured - * 2026-08-05: a search for {@code pqRegistryHash} across the deployment configuration returns - * nothing). So the whole height-schedule machinery was inert and the answer above the arming - * height was still "verify this year-old header against today's keys", which IS the height-less - * lookup defect, unrepaired. Falling back is correct BELOW the arming height, where no certificate is being judged; at and above it, + * 2026-08-05: {@code grep -rn pqRegistryHash --include=*.json deploy/ monitoring/} returns + * nothing). So the whole D-081 machinery was inert and the answer above the arming height was + * still "verify this year-old header against today's keys", which IS D2/T2, unrepaired. Falling + * back is correct BELOW the arming height, where no certificate is being judged; at and above it, * the honest answer to "which keys were in force here" is a refusal, not a guess. * *

    Read through {@link PqAnchorProducer#config()} so the value is the same one the producer and @@ -2708,13 +2756,21 @@ public final class FalconSealSupport { } /** - * The block number at and after which the Falcon quorum certificate becomes BLOCKING (a valid - * >= 2f+1 Falcon quorum is required for a block to be accepted). Before this block the Falcon - * seals are additive / log-only. Configured via {@code aere.falcon.forkBlock} / {@code - * AERE_FALCON_FORKBLOCK}; defaults to {@link Long#MAX_VALUE} (never blocking, pure log-only) so - * an unconfigured node behaves exactly like the additive baseline. + * The block number at and after which the LEGACY per-block Falcon rule + * ({@link org.hyperledger.besu.consensus.qbft.headervalidationrules.FalconSealValidationRule}) + * becomes blocking. Configured via {@code aere.falcon.forkBlock} / {@code AERE_FALCON_FORKBLOCK}; + * defaults to {@link Long#MAX_VALUE} (never blocking, pure log-only) so an unconfigured node + * behaves exactly like the additive baseline. * - *

    AERE LATE-ANCHOR HEIGHT (2026-08-03): this used to re-read the property on every call and swallow a + *

    ON CHAIN 2800 THIS ARMS A RULE THAT IS ALREADY RETIRED, so setting it changes nothing + * (finding D-235, corrected 2026-08-19). The legacy rule stands down at + * {@code PqAnchorConfig.legacyFalconRuleRetirementBlock()} = the anchor block, 13,014,000, and + * this property is set to 14,050,000 - above it. Until 2026-08-19 our own public texts said a + * per-block 2f+1 Falcon quorum had been blocking since that height; the claim was withdrawn the + * same day. What is defensible: at every 32nd height a certificate of at least K valid Falcon-512 + * seals sits under the block hash, and without it that block does not finalize. + * + *

    AERE D-079 (2026-08-03): this used to re-read the property on every call and swallow a * NumberFormatException into {@link Long#MAX_VALUE} with a WARN line. That is the finding, in one * method: the single value the entire post-quantum enforcement layer is gated on could silently * become "never blocking", and the only trace was a log line nothing reads. The value is now @@ -2729,7 +2785,7 @@ public final class FalconSealSupport { } /** - * AERE LATE-ANCHOR HEIGHT: the DECLARED height at or after which the on-chain late-anchor registry contract is + * AERE D-079: the DECLARED height at or after which the on-chain late-anchor registry contract is * expected to be observable, or {@link Long#MAX_VALUE} when undeclared. * * @return the declared anchor observation height @@ -2739,7 +2795,7 @@ public final class FalconSealSupport { } /** - * AERE LATE-ANCHOR HEIGHT: the first height at which this node validated a header at or after the blocking + * AERE D-079: the first height at which this node validated a header at or after the blocking * height while the anchored registry was NOT active, or -1 if that has never happened. * *

    The configuration guard refuses the misconfiguration that CAUSES this. It cannot refuse the @@ -2806,7 +2862,7 @@ public final class FalconSealSupport { fork); return; } - // AERE GENESIS BINDING REPAIR (2026-08-02). This used to be LOG.error and nothing else, and the comment + // AERE A8 REPAIR (2026-08-02). This used to be LOG.error and nothing else, and the comment // above still says "diagnostic only". That was measured to be the wrong trade. A node that // starts here does not stay harmless: it joins the fleet, reaches the activation height, and // from that height rejects every header that carries a certificate. Measured on three @@ -2822,12 +2878,12 @@ public final class FalconSealSupport { // that this class never throws is a promise about the PER-BLOCK path, and it is kept: nothing // below this constructor throws. // - // The refusal is inert wherever forkBlock is unset, because + // The refusal is inert on chain 2800 as it stands today, because forkBlock is unset there, so // this code cannot stop a live validator that is running now. throw new ActivationConfigException( ActivationConfigException.Kind.UNSAFE, "AERE-PQC-REG-ARM-01", - "AERE PQC GENESIS-BINDING: REFUSING TO START (fail-closed). Falcon blocking is " + "AERE PQC A8: REFUSING TO START (fail-closed). Falcon blocking is " + "configured at forkBlock=" + fork + " but the active Falcon registry is NOT ADDRESS-BOUND" @@ -2855,7 +2911,7 @@ public final class FalconSealSupport { } /** - * AERE ROW BINDING (2026-08-06). REFUSE TO START when this node is ARMED and the registry it will be + * AERE D-146 (2026-08-06). REFUSE TO START when this node is ARMED and the registry it will be * held to carries no binding proofs. * *

    WHAT IT ADDS OVER {@code AERE-PQC-REG-ARM-01}. That guard asks whether the registry is @@ -2872,23 +2928,23 @@ public final class FalconSealSupport { * signature by the validator repairs ATTRIBUTION. * *

    WHEN IT FIRES. Only when this node is armed: {@code aere.falcon.forkBlock} is configured, or - * the certificate anchor is active per {@link #anchorArmedFrom()}. Both are unset as it - * stands, so this refusal is INERT on the live fleet exactly as AERE-PQC-REG-ARM-01 is, and it + * the certificate anchor is active per {@link #anchorArmedFrom()}. Both are unset on chain 2800 as + * it stands, so this refusal is INERT on the live fleet exactly as AERE-PQC-REG-ARM-01 is, and it * cannot stop a validator that is running today. That inertness is not asserted here, it is * measured by {@code PqInertBinaryTest}, which drives a node with no {@code aere.pq.*} * and no {@code aere.falcon.forkBlock} property at all through this constructor. * *

    WHAT IT DOES NOT JUDGE. A node armed with NO registry file at all returns without a word. - * Row binding is about a row that credits a seal to the wrong validator, and a registry with no rows + * D-146 is about a row that credits a seal to the wrong validator, and a registry with no rows * credits nobody; that condition belongs to AERE-PQC-REG-ARM-01 and AERE-PQC-CFG-UNSAFE-08, which * own it and name the numbers. See the comment at the return itself for what was measured when * this guard tried to own it too. * *

    WHY IT IS NOT BEHIND THE EMERGENCY BYPASS. {@code aere.falcon.registry.mismatch.allow} - * overrides the genesis hash gate, which answers "is this the file genesis named" - a question about + * overrides the A8 hash gate, which answers "is this the file genesis named" - a question about * configuration drift, recoverable by installing the right file. This one answers "can this * registry be trusted to say who signed", and the anchor contract is IMMUTABLE once written: a - * fleet armed over unbound rows carries that defect for the life of the chain. The way out of THIS one + * fleet armed over unbound rows carries D-146 for the life of the chain. The way out of THIS one * is the same as for AERE-PQC-REG-ARM-01 - do not arm, or disarm: unset {@code * aere.falcon.forkBlock}, or set {@code aere.pq.anchor.disable=true}, which makes {@link * #anchorArmedFrom()} return {@link Long#MAX_VALUE} and leaves {@code PqEmergencyShoutRule} @@ -2911,11 +2967,11 @@ public final class FalconSealSupport { */ private void requireRegistryBindingProofsOrAbort() { if (forkBlock() == Long.MAX_VALUE && anchorArmedFrom() == Long.MAX_VALUE) { - return; // nothing armed on this node; inert + return; // nothing armed on this node; inert, exactly as on chain 2800 today } if (registrySourcePath == null) { - // AN ARMED NODE WITH NO REGISTRY AT ALL IS NOT A ROW-BINDING DEFECT, and this return is the - // difference between a guard and a blanket. Row binding is about mis-ATTRIBUTION: a row that credits a + // AN ARMED NODE WITH NO REGISTRY AT ALL IS NOT A D-146 DEFECT, and this return is the + // difference between a guard and a blanket. D-146 is mis-ATTRIBUTION: a row that credits a // seal to a validator who never held the key on it. That requires ROWS. With no registry // there are no rows, indexToAddress is empty, the eligible-signer set is empty, and nobody // can be credited with anything - there is nothing for a forged binding to say. @@ -2943,7 +2999,7 @@ public final class FalconSealSupport { } /** - * AERE COVERAGE MARGIN (2026-08-03). REFUSE TO START when the armed Falcon threshold K is above the number of + * AERE D-078 (2026-08-03). REFUSE TO START when the armed Falcon threshold K is above the number of * anchored-key holders this fleet is GUARANTEED to have among a block's ECDSA committers. * *

    WHY THIS EXISTS. The 2026-08-02 repair correctly removed the fleet-wide coverage question from @@ -2961,16 +3017,16 @@ public final class FalconSealSupport { * it can only LOWER K later, and a guard must not be satisfied by a lever an operator may not pull. * *

    WHY IT MAY THROW HERE. Same reasoning, same path and same precedent as AERE-PQC-CFG-UNSAFE-04 - * and the genesis-binding refusal directly above: this is the constructor, the network is not up, the QBFT state + * and the A8 refusal directly above: this is the constructor, the network is not up, the QBFT state * machine does not exist, and no header has been offered to anyone. The promise that this class * never throws is a promise about the PER-BLOCK path, and it is kept. * *

    WHY IT IS NOT A RUNTIME CHECK. A per-block version of this comparison would be exactly the - * defect rebuilt: a fleet-wide fact, false on every node at the same height after an + * defect of D-078 rebuilt: a fleet-wide fact, false on every node at the same height after an * ordinary vote, answered with a halting action. The runtime half stays a REPORT, in {@link * #reportRegistryCoverage}, and decides nothing. * - *

    INERT WHERE {@code aere.pq.anchorBlock} IS UNSET: there is no + *

    INERT ON CHAIN 2800 as it stands: {@code aere.pq.anchorBlock} is unset there, so there is no * threshold and no comparison. This code cannot stop a validator that is running now. */ private void validateThresholdReachabilityOrAbort() { @@ -3034,7 +3090,7 @@ public final class FalconSealSupport { throw new ActivationConfigException( ActivationConfigException.Kind.UNSAFE, "AERE-PQC-CFG-UNSAFE-08", - "AERE PQC COVERAGE: REFUSING TO START (fail-closed). The Falcon anchor is armed at height " + "AERE PQC D-078: REFUSING TO START (fail-closed). The Falcon anchor is armed at height " + anchor.anchorBlock() + " with a staged threshold that reaches K=" + k @@ -3052,7 +3108,7 @@ public final class FalconSealSupport { + ". WHAT THIS MEANS: with every node honest and every node up, a proposer can fail to " + "assemble a certificate, and it fails on every node at once because the validator set " + "is consensus state - the chain stops in a state where the re-anchoring transaction " - + "that would repair it can no longer be carried by any block. That is an unreachable threshold. WHAT TO " + + "that would repair it can no longer be carried by any block. That is D-078. WHAT TO " + "DO: re-anchor the manifest so that every validator holds a key (this is what makes " + "the standing 'grow to N>=9 before arming' order safe - the manifest has to grow WITH " + "the set, not after it), or lower the " @@ -3064,7 +3120,7 @@ public final class FalconSealSupport { } /** - * AERE COVERAGE MARGIN: how many validators hold an anchored Falcon key, counting a late anchor that has not + * AERE D-078: how many validators hold an anchored Falcon key, counting a late anchor that has not * landed yet the same way {@link #expectedFleetSize()} does, so the two numbers being compared are * read at the same moment from the same manifest. * @@ -3112,9 +3168,9 @@ public final class FalconSealSupport { } /** - * AERE COVERAGE MARGIN: the height at which a validator set was last observed, or {@code -1} when none ever + * AERE D-078: the height at which a validator set was last observed, or {@code -1} when none ever * was. Diagnostic, and the only way a test can tell "the gate is being fed" from "the gate happens - * to say yes anyway", which is the difference the coverage repair turns on. + * to say yes anyway", which is the difference the D-078 repair turns on. * * @return the observation height, or -1 if no validator set has been observed */ @@ -3134,7 +3190,7 @@ public final class FalconSealSupport { *

  • that registry binds THIS node's own Falcon index to a validator address. * * - *

    AERE COVERAGE MARGIN (2026-08-02): condition 3 used to be "that registry COVERS every validator in + *

    AERE D-078 (2026-08-02): condition 3 used to be "that registry COVERS every validator in * the last observed validator set", and that is the sentence that stopped the chain. Coverage * is a property of the validator set, which is consensus state, so an ordinary add-validator vote * falsifies it on every node at the same height; the old answer to that was to switch attachment @@ -3160,7 +3216,7 @@ public final class FalconSealSupport { * cannot check peer BINARY VERSION, and it is emphatically NOT what makes the halting order * impossible. Until 2026-08-02 it asked the FLEET question instead - does the registry cover * every observed validator - and switched attachment off when the answer was no; see the - * coverage note on the method below for why that was a chain stop rather than a safeguard. + * D-078 note on the method below for why that was a chain stop rather than a safeguard. *

  • The CHAIN-RELATIVE guard, {@link #verifyAttachHeightAgainstChainHeadOrAbort}, is what * closes that last gap. It refuses to START a node whose activation height is not safely * ahead of the chain head, so the height cannot be one that the fleet has already walked @@ -3204,7 +3260,7 @@ public final class FalconSealSupport { } return false; } - // AERE COVERAGE MARGIN (2026-08-02), CONDITION 3, REPLACED. What stands here now is a LOCAL fact: does the + // AERE D-078 (2026-08-02), CONDITION 3, REPLACED. What stands here now is a LOCAL fact: does the // anchored registry bind MY OWN index to an address, so that every verifier can resolve my seal // to a signer. It is a property of this node and the anchored manifest, and nothing that happens // to the validator set can falsify it. @@ -3218,7 +3274,7 @@ public final class FalconSealSupport { // height, because the validator set is consensus state. Every node stops attaching, so no // proposer can gather K seals, so PqAnchorProducer refuses for every proposer in turn and // the chain stops - and it stops in a state where the re-anchoring transaction that would - // repair it can no longer be carried by any block. That is the same halt, measured in + // repair it can no longer be carried by any block. That is D-078, measured in // PqForkValidatorSetChangeTest. // * the input it read is fed by observeValidators, whose only caller stands down at the anchor // height. Above that height the answer was either FROZEN at a set from below it, or - on any @@ -3269,7 +3325,7 @@ public final class FalconSealSupport { } /** - * AERE COVERAGE MARGIN (2026-08-02). REPORT what the anchored registry covers, and what that costs in + * AERE D-078 (2026-08-02). REPORT what the anchored registry covers, and what that costs in * liveness margin. This decides nothing: it is called after the attachment gate has already said * yes, and its only effect is a log line. * @@ -3318,7 +3374,7 @@ public final class FalconSealSupport { LOG.warn( "AERE PQC: the anchored registry does NOT cover the validator set at block {}: {} of {} " + "validators hold an anchored Falcon key. Seal attachment CONTINUES - switching it off " - + "here is what stops a chain - but the post-quantum margin has moved: in the " + + "here is what stops a chain (D-078) - but the post-quantum margin has moved: in the " + "worst case only {} keyed validator(s) are among the {} ECDSA committers of a block. " + "Compare that with the Falcon threshold K in force: if it is above that number, a " + "proposer can legitimately fail to assemble a certificate. RE-ANCHOR the manifest for " @@ -3331,14 +3387,14 @@ public final class FalconSealSupport { } /** - * AERE COVERAGE MARGIN. The number of anchored-key holders a block's ECDSA committer set is GUARANTEED to + * AERE D-078. The number of anchored-key holders a block's ECDSA committer set is GUARANTEED to * contain, in the worst case, when {@code validators} validators are in the set and {@code keyed} * of them hold an anchored Falcon key. * *

    A block needs {@code ceil(2N/3)} ECDSA committed seals. An adversarial (or merely unlucky) * choice of committers takes every unkeyed validator first, so the guaranteed keyed count is * {@code quorum - (N - keyed)}, floored at zero. A Falcon threshold K above this number is a - * threshold the chain is not guaranteed to be able to meet, which is exactly the shape of that halt: + * threshold the chain is not guaranteed to be able to meet, which is exactly the shape of D-078: * at N=7 with all 7 keyed and K=5 the margin is exactly zero, one added unkeyed validator holds it * at zero, and a second takes it negative. * @@ -3355,7 +3411,7 @@ public final class FalconSealSupport { } /** - * AERE COVERAGE MARGIN (2026-08-03). By how much a Falcon threshold {@code k} exceeds what the fleet is + * AERE D-078 (2026-08-03). By how much a Falcon threshold {@code k} exceeds what the fleet is * GUARANTEED to be able to produce. Zero means reachable; any positive number is a threshold a * proposer can legitimately fail to meet with every node honest and every node up. * @@ -3393,15 +3449,131 @@ public final class FalconSealSupport { if (!attachmentArmed(blockNumber)) { return Optional.empty(); } - try { - final FalconSigner signer = new FalconSigner(); - signer.init(true, localPrivateKey); - final byte[] sig = signer.generateSignature(commitHash.toArray()); - return Optional.of(new FalconSeal(localIndex, Bytes.wrap(sig))); - } catch (final RuntimeException e) { - LOG.warn("AERE PQC: Falcon signing failed (ECDSA seal unaffected): {}", e.toString()); + // AERE AGILITY step 4 (2026-08-24): signing goes through the scheme layer. The same + // FalconSigner underneath, but the path is now the one the hybrid will use too; the 751 + // baseline tests prove the equivalence. + final java.util.Optional sig = + ((FalconSealScheme) SealSchemes.FALCON_512).signWithParams(localPrivateKey, commitHash.toArray()); + if (sig.isEmpty()) { + LOG.warn("AERE PQC: Falcon signing failed (ECDSA seal unaffected)"); return Optional.empty(); } + return Optional.of(new FalconSeal(localIndex, Bytes.wrap(sig.get()))); + } + + /** + * Property naming the first height at which this node ATTACHES a post-quantum seal to its own + * PREPARE messages. Absent = never, which is the configuration of every node today. + * + *

    SEPARAT de {@code aere.falcon.attachBlock}, si separarea e obligatorie: daca emiterea pe + * PREPARE ar porni odata cu cea pe commit, ridicarea binarului pe flota ar deveni o zi de flag. + * Asa, binarul poate sta luni de zile pe toate nodurile inainte ca vreunul sa emita ceva nou. + */ + public static final String PREPARE_ATTACH_PROPERTY = "aere.pq.preparePq.attachBlock"; + + /** Environment fallback for {@link #PREPARE_ATTACH_PROPERTY}. */ + public static final String PREPARE_ATTACH_ENV = "AERE_PQ_PREPAREPQ_ATTACHBLOCK"; + + /** + * The configured PREPARE attachment height, read fresh on every call. + * + *

    Absent = {@link Long#MAX_VALUE}, i.e. never. A value that is PRESENT but unreadable REFUSES + * loudly instead of disarming: the lesson paid for by the anchor loader is that a mistyped + * character must never boot the node DISARMED, because then nobody finds out. + * + * @return the height, or Long.MAX_VALUE when unset + */ + public static long prepareAttachBlock() { + final String raw = resolve(PREPARE_ATTACH_PROPERTY, PREPARE_ATTACH_ENV); + if (raw == null || raw.isBlank()) { + return Long.MAX_VALUE; + } + try { + final long v = Long.parseLong(raw.trim()); + if (v < 0) { + throw new NumberFormatException("negative"); + } + return v; + } catch (final NumberFormatException e) { + throw new ActivationConfigException( + ActivationConfigException.Kind.SYNTAX, + "AERE-PQC-PREPARE-CONF-01", + "AERE PQ PREPARE: " + + PREPARE_ATTACH_PROPERTY + + " is set to '" + + raw + + "', which is not a non-negative block height. A node must REFUSE to start rather " + + "than silently run with PREPARE attachment disarmed: a disarmed node looks exactly " + + "like a correctly configured one until the day it matters."); + } + } + + /** + * Sign this node's own PREPARE, when the PREPARE attachment gate is open at this height. + * + *

    It requires THREE things, and each closes one way of being wrong: + * + *

      + *
    • {@code signingEnabled}: the node holds a key. Without one nothing is emitted, and the + * ECDSA path is not touched in any way. + *
    • {@link #attachmentArmed(long)}: the same registry-coverage conditions as commit, AND the + * fact that the fleet already emits seals on commit. A PREPARE seal on a fleet that does not + * emit on commit would be something new in a network that has not yet seen anything new. + *
    • its own height, above. + *
    + * + *

    NEVER THROWS except for the strict configuration case above: a signing failure is a log line + * and an empty value, exactly as at commit, because the ECDSA path must not be disturbed. + * + * @param blockNumber the height being prepared + * @param message the domain-separated PREPARE message (see PqAnchor.prepareMessage) + * @return the seal, or empty when any gate is shut + */ + public Optional signPrepare(final long blockNumber, final Bytes32 message) { + if (!signingEnabled) { + return Optional.empty(); + } + if (!attachmentArmed(blockNumber)) { + return Optional.empty(); + } + if (blockNumber < prepareAttachBlock()) { + return Optional.empty(); + } + final java.util.Optional sig = + ((FalconSealScheme) SealSchemes.FALCON_512).signWithParams(localPrivateKey, message.toArray()); + if (sig.isEmpty()) { + LOG.warn("AERE PQ PREPARE: Falcon signing failed (ECDSA path unaffected)"); + return Optional.empty(); + } + final long n = preparesSealed.incrementAndGet(); + // LOG VOLUME IS A DECISION, not an oversight. One line per PREPARE would be two lines per + // second per node, which is exactly the kind of log that gets trained away (see D-153, where a + // permanent ERROR made an entire log worthless). The first emission is worth a line, because it + // is the moment the node starts doing something new; after that, one line every 500, so a + // testnet still has a number to count. + if (n == 1L) { + LOG.info( + "AERE PQ PREPARE: this node EMITTED its first post-quantum seal on a PREPARE, at " + + "height {} (gate {}={}). From here on its PREPAREs carry a seal.", + blockNumber, + PREPARE_ATTACH_PROPERTY, + prepareAttachBlock()); + } else if (n % 500L == 0L) { + LOG.info("AERE PQ PREPARE: {} seals emitted on PREPAREs since startup.", n); + } + return Optional.of(new FalconSeal(localIndex, Bytes.wrap(sig.get()))); + } + + /** + * How many seals this node has emitted on PREPAREs since startup. + * + *

    It exists so that the COVERAGE step can be measured: without a number, arming enforcement + * would be a bet. Read from the log on a testnet, and from this value in tests. + * + * @return the count + */ + public long preparesSealed() { + return preparesSealed.get(); } /** @@ -3417,7 +3589,7 @@ public final class FalconSealSupport { } /** - * HEIGHT SCHEDULE. The Falcon public key registered for an index AT A HEIGHT: the one carried by the + * D-081. The Falcon public key registered for an index AT A HEIGHT: the one carried by the * registry the chain's schedule makes active there. * *

    WHY A HEIGHT IS NEEDED AT ALL. A certificate inside a block at height h was produced under @@ -3429,7 +3601,7 @@ public final class FalconSealSupport { * *

    Returns the head registry's key when no binding is active at that height, which is every * height below the schedule's first entry and every height on a chain with no schedule at all. So - * a node with a single registry gets exactly what it got before the height schedule existed. + * a node on chain 2800 as it stands today gets exactly what it got before D-081. */ private FalconPublicKeyParameters keyAt( final long blockNumber, final int validatorIndex, final boolean historic) { @@ -3442,11 +3614,11 @@ public final class FalconSealSupport { final Optional required = PqRegistryHash.requiredHashAt(schedule, blockNumber); if (required.isEmpty()) { - // SCHEDULE BOUNDARY (2026-08-15). The only historical question ever asked one block - // below the schedule's first entry is about the certificate carried by the block at - // blockNumber+1, whose governing registry is the one bound EXACTLY at blockNumber+1. - // Answer it from that entry's VERIFIED bound registry, never from the unverified head - // file. Exactly one block: at blockNumber+2 below the schedule nothing changes. + // D-228. The only historical question ever asked one block below the schedule's first entry + // is about the certificate carried by the block at blockNumber+1, whose governing registry + // is the one bound EXACTLY at blockNumber+1. Answer it from that entry's VERIFIED bound + // registry, never from the unverified head file. Exactly one block: at blockNumber+2 below + // the schedule nothing changes. final Optional nextEntry = PqRegistryHash.requiredHashAt(schedule, blockNumber + 1); if (nextEntry.isPresent() && nextEntry.get().block() == blockNumber + 1) { @@ -3489,9 +3661,9 @@ public final class FalconSealSupport { } /** - * HEIGHT SCHEDULE. The validator ADDRESS bound to a registry index at a height. + * D-081. The validator ADDRESS bound to a registry index at a height. * - *

    LOOKUP HARDENING (b-v2): PRIVATE, with the caller's motive as an argument. The two public + *

    D2 HARDENING (b-v2): PRIVATE, with the caller's motive as an argument. The two public * doors are {@link #addressForIndexAtHistoric} and {@link #addressForIndexAtOwnHead}, each of * which passes a constant. Nothing outside this file can choose the value, so the one shared * resolution body cannot drift between the two paths and no caller can pick the wrong flag. @@ -3512,10 +3684,10 @@ public final class FalconSealSupport { blockNumber, validatorIndex, "no schedule was ever loaded", historic); } if (PqRegistryHash.requiredHashAt(schedule, blockNumber).isEmpty()) { - // SCHEDULE BOUNDARY (2026-08-15). Same alignment as in keyAt: one block below the - // schedule's first entry the subject is the certificate of the block at blockNumber+1, - // governed by the registry bound EXACTLY there, so the answer comes from that entry's - // VERIFIED bound registry and mirrors what this method answers at blockNumber+1 itself. + // D-228. Same alignment as in keyAt: one block below the schedule's first entry the subject + // is the certificate of the block at blockNumber+1, governed by the registry bound EXACTLY + // there, so the answer comes from that entry's VERIFIED bound registry and mirrors what + // this method answers at blockNumber+1 itself. final Optional nextEntry = PqRegistryHash.requiredHashAt(schedule, blockNumber + 1); if (nextEntry.isPresent() && nextEntry.get().block() == blockNumber + 1) { @@ -3550,14 +3722,14 @@ public final class FalconSealSupport { } /** - * HEIGHT SCHEDULE / LOOKUP HARDENING (b-v2). THE HISTORY DOOR. Verify a Falcon seal carried by a header this + * D-081 / D2 HARDENING (b-v2). THE HISTORY DOOR. Verify a Falcon seal carried by a header this * node RECEIVED, against the key set the chain required at that height. * *

    WHY THE MOTIVE IS IN THE NAME AND NOT IN THE HEIGHT, measured on 2026-08-06. The first shape * of hardening (b) refused whenever no height binding existed at or above the arming height, and * decided that from the block number alone. That stopped the two paths that work on THIS NODE'S * OWN head: restoring seals from disk after a restart, and proposing. Six tests went red, five in - * {@code PqSealPersistenceTest} and one in {@code PqForkValidatorSetChangeTest}, and the refusal + * {@code PqSealPersistenceTest} and one in {@code PqForkValidatorSetChangeTest}, and the D078 * message says the consequence outright - the node refuses to propose, so it stops producing * blocks. In every one of the six the number presented to the guard was 1030 against an arming * height of 1000, identical to what a real historical question would present in the same process @@ -3565,7 +3737,7 @@ public final class FalconSealSupport { * *

    Reaching THIS method means the node is judging somebody else's claim about a height it did * not build. "Which keys were in force here" then has an answer that is not this node's current - * registry, and answering from the head registry anyway is that defect verbatim: a header produced under + * registry, and answering from the head registry anyway is D2/T2 verbatim: a header produced under * one key set checked against another, with success reported. * * @param blockNumber the height of the header carrying the seal @@ -3584,7 +3756,7 @@ public final class FalconSealSupport { } /** - * LOOKUP HARDENING (b-v2). THE OWN-HEAD DOOR. Verify a Falcon seal over a block this node holds as its + * D2 HARDENING (b-v2). THE OWN-HEAD DOOR. Verify a Falcon seal over a block this node holds as its * own head, or is building right now. * *

    It does not refuse for a missing height binding, because at this node's own head the head @@ -3613,7 +3785,7 @@ public final class FalconSealSupport { } /** - * HEIGHT SCHEDULE / LOOKUP HARDENING (b-v2). THE HISTORY DOOR, address half. See {@link #verifyAtHistoric}. + * D-081 / D2 HARDENING (b-v2). THE HISTORY DOOR, address half. See {@link #verifyAtHistoric}. * * @param blockNumber the height of the header being validated * @param validatorIndex the registry index carried by a Falcon seal @@ -3624,7 +3796,7 @@ public final class FalconSealSupport { } /** - * LOOKUP HARDENING (b-v2). THE OWN-HEAD DOOR, address half. See {@link #verifyAtOwnHead}. + * D2 HARDENING (b-v2). THE OWN-HEAD DOOR, address half. See {@link #verifyAtOwnHead}. * * @param blockNumber this node's own head, or the block it is building * @param validatorIndex the registry index @@ -3635,14 +3807,14 @@ public final class FalconSealSupport { } /** - * LOOKUP HARDENING (b). The one decision the whole hardening turns on, isolated so that removing it is - * a one-line edit and the negative control can prove the hardening test goes red without it. + * D2 HARDENING (b). The one decision the whole hardening turns on, isolated so that removing it is + * a one-line edit and the negative control can prove the D2 test goes red without it. * *

    THE MEASURED DEFECT. Both height-resolved lookups used to answer a height they had no binding * for by returning the HEAD registry - the key set in force right now. Below the arming height * that is correct and costs nothing: no certificate is being judged there. At and above it, it is - * the height-less lookup defect verbatim: "an armed node verifies a year-old header against the - * keys it holds today", so one rotation makes every block between the arming height and the rotation unverifiable, and the + * D2/T2 verbatim: "an armed node verifies a year-old header against the keys it holds today", so + * one rotation makes every block between the arming height and the rotation unverifiable, and the * node reports success while doing it. Refusing is the only answer that does not assert a check * that was not performed. * @@ -3653,7 +3825,7 @@ public final class FalconSealSupport { * config.pqRegistryHash} plus {@code aere.falcon.registry.history} and restart. Compare the * silence this replaces, where the same node imports the whole chain and calls it verified. * - *

    LOOKUP HARDENING (b-v2), 2026-08-06. The condition gained ONE term, {@code historic}, and + *

    D2 HARDENING (b-v2), 2026-08-06. The condition gained ONE term, {@code historic}, and * that term is the whole of the second repair. The first shape refused on height alone; the * six tests it turned red were all asking about this node's OWN head at height 1030 with an * arming height of 1000, which is the same pair of numbers a genuinely historical question @@ -3678,11 +3850,11 @@ public final class FalconSealSupport { } /** - * LOOKUP HARDENING (b). The address half of {@link #headRegistryKeyOrRefuse}, with the same rule and + * D2 HARDENING (b). The address half of {@link #headRegistryKeyOrRefuse}, with the same rule and * for the same reason: at and above the arming height an unbound height has no answer, and {@code * PqAnchorSealsRule} refuses an index it cannot bind to an address rather than skipping it. * - *

    LOOKUP HARDENING (b-v2): same one added term as {@link #headRegistryKeyOrRefuse}. + *

    D2 HARDENING (b-v2): same one added term as {@link #headRegistryKeyOrRefuse}. * * @param blockNumber the height being asked about * @param validatorIndex the registry index @@ -3704,7 +3876,7 @@ public final class FalconSealSupport { } /** - * LOOKUP HARDENING (b). Say it once per configuration change, not once per block: at a 523 ms block + * D2 HARDENING (b). Say it once per configuration change, not once per block: at a 523 ms block * period a per-block ERROR is itself a hazard on this fleet, and the refusal is already visible as * a stopped node. * @@ -3714,9 +3886,9 @@ public final class FalconSealSupport { private void shoutUnboundHeight(final long blockNumber, final String why) { if (loggedUnboundArmedHeight.compareAndSet(false, true)) { LOG.error( - "AERE PQC LOOKUP-HARDENING: REFUSING to resolve Falcon keys at height {} - this node is ARMED from {} " + "AERE PQC D2: REFUSING to resolve Falcon keys at height {} - this node is ARMED from {} " + "and {}. Until 2026-08-06 this fell back to the registry in force at the HEAD, " - + "which is the height-less lookup defect: a header produced under one key set was checked against " + + "which is the D2/T2 defect: a header produced under one key set was checked against " + "another, and one key rotation would have made every block above the arming height " + "unverifiable while the node reported success. Every header at or above the arming " + "height will now be REFUSED until the height-to-registry binding exists. WHAT TO DO: " @@ -3737,13 +3909,14 @@ public final class FalconSealSupport { if (pub == null || commitHash == null || signature == null) { return false; } - try { - final FalconSigner verifier = new FalconSigner(); - verifier.init(false, pub); - return verifier.verifySignature(commitHash.toArray(), signature.toArray()); - } catch (final RuntimeException e) { - LOG.debug("AERE PQC: Falcon verify threw for index {}: {}", validatorIndex, e.toString()); - return false; + // AERE AGILITY step 4: verification goes through the scheme layer, on the registry form + // (raw h, 896 bytes). verifyRaw never throws; a false here is an invalid seal, + // exactly the old contract. + final boolean valid = + SealSchemes.FALCON_512.verifyRaw(pub.getH(), commitHash.toArray(), signature.toArray()); + if (!valid) { + LOG.debug("AERE PQC: Falcon seal did not verify for index {}", validatorIndex); } + return valid; } } diff --git a/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/HybridSealProducer.java b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/HybridSealProducer.java new file mode 100644 index 0000000..591ede1 --- /dev/null +++ b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/HybridSealProducer.java @@ -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 localKeys; + // one shout per missing scheme, not one per block: a permanent alarm is learned and ignored + private final Set 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 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. + * + *

    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 sealsFor(final long blockNumber, final Bytes message) { + if (!attachmentArmedAt(blockNumber) || message == null) { + return List.of(); + } + try { + final Set required = schedule.schemesAt(blockNumber); + final List 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 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 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); + } + } +} diff --git a/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/HybridSealSupport.java b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/HybridSealSupport.java new file mode 100644 index 0000000..619cec9 --- /dev/null +++ b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/HybridSealSupport.java @@ -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. / (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 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 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 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 + " 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 schedule() { + return Optional.ofNullable(schedule); + } + + /** Returns the registry, when the hybrid pair is configured. + * + * @return the registry, or empty */ + public Optional registry() { + return Optional.ofNullable(registry); + } + + /** Returns the producer; disarmed (never emits) when nothing is configured. + * + * @return the producer */ + public HybridSealProducer producer() { + return producer; + } +} diff --git a/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/HybridSignerRegistry.java b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/HybridSignerRegistry.java new file mode 100644 index 0000000..9089ab2 --- /dev/null +++ b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/HybridSignerRegistry.java @@ -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= + * count= + * .addr=<20-byte hex> mandatory for every index 0..count-1 + * .key.= 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> keys; + private final Map addresses; + + private HybridSignerRegistry( + final long chainId, + final NavigableMap> keys, + final Map 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> keys = new TreeMap<>(); + final Map 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 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 publicKey(final int index, final String schemeId) { + final Map 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 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> row : keys.entrySet()) { + writeAll(out, uint32be(row.getKey())); + writeAll(out, addresses.get(row.getKey())); + writeAll(out, uint32be(row.getValue().size())); + for (final Map.Entry 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 schemesOf(final int index) { + final Map 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; + } +} diff --git a/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchor.java b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchor.java index 59c2d86..0976c24 100644 --- a/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchor.java +++ b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchor.java @@ -1,5 +1,5 @@ /* - * Copyright contributors to Besu / Aere Network. + * Copyright contributors to Besu / AERE Network. * * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at @@ -98,6 +98,21 @@ public final class PqAnchor { public static final Bytes COMMIT_DOMAIN_BYTES = Bytes.wrap(COMMIT_DOMAIN.getBytes(StandardCharsets.US_ASCII)); + /** + * The PREPARE domain label. AERE PQ (2026-08-28). + * + *

    SEPARATE FROM COMMIT, and the separation is a security requirement, not a matter of style. + * If a PREPARE seal signed the same bytes as a commit seal, an adversary could take the PREPARE + * dat CINSTIT de un validator si sa il lipeasca pe un COMMIT falsificat: semnatura ar verifica, + * si chiar regula pusa sa apere commitul ar fi ocolita. Un singur sir schimbat in preimagine face + * cele doua semnaturi netransferabile. + */ + public static final String PREPARE_DOMAIN = "AERE-PQ-PREPARE-1"; + + /** The prepare domain label as raw bytes. */ + public static final Bytes PREPARE_DOMAIN_BYTES = + Bytes.wrap(PREPARE_DOMAIN.getBytes(StandardCharsets.US_ASCII)); + /** Orders Falcon seals by their registry index, ascending. */ public static final Comparator BY_INDEX = Comparator.comparingInt(FalconSeal::getValidatorIndex); @@ -216,6 +231,48 @@ public final class PqAnchor { return Hash.keccak256(out.encoded()); } + /** + * The message a PREPARE seal signs: M = keccak256(RLP[PREPARE_DOMAIN, chainId, blockNumber, + * round, digest]). + * + *

    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. * diff --git a/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorConfig.java b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorConfig.java index ce9f081..398941c 100644 --- a/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorConfig.java +++ b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorConfig.java @@ -1,5 +1,5 @@ /* - * Copyright contributors to Besu / Aere Network. + * Copyright contributors to Besu / AERE Network. * * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at @@ -103,19 +103,18 @@ import org.slf4j.LoggerFactory; * here happens before the node has joined the quorum, is visible in {@code systemctl status} in * second zero, and is repaired with one line and one restart, at a pace the operator controls. The * risk that IS real is a bad shared template plus a parallel fleet restart: at quorum 5 of 7 that is - * not degradation, it is a dead chain. The net for it is the operating rule that goes with this - * configuration - restart one at a time, never in parallel - and a preflight that computes its - * verdict from THIS code path rather than from a second reading of the same strings. + * not degradation, it is a dead chain. The net for it is the one already written in the runbook - + * restart one at a time, never in parallel - and a preflight that computes its verdict from THIS + * code path rather than from a second reading of the same strings. * - *

    HONEST LIMITATION, stated in code because it is the same defect class as an unbound - * registry. The values here are read from LOCAL system properties or environment variables, - * exactly like {@code aere.falcon.registry} is today. They are NOT yet read from the genesis {@code - * config.qbft} / {@code config.transitions.qbft}, and there is NO consensus binding on them: two - * nodes configured with different H or different K schedules will disagree about which headers are - * valid. Wiring these to genesis, and refusing to start when the Falcon registry does not match the - * genesis {@code pqRegistryHash}, is a PRECONDITION of arming and is tracked as the fork-activation - * and registry work items. Until that lands, a non-default value here is a laboratory setting, not - * a deployment. + *

    HONEST LIMITATION, stated in code because it is the same defect class as A8. The values + * here are read from LOCAL system properties or environment variables, exactly like {@code + * aere.falcon.registry} is today. They are NOT yet read from the genesis {@code config.qbft} / + * {@code config.transitions.qbft}, and there is NO consensus binding on them: two nodes configured + * with different H or different K schedules will disagree about which headers are valid. Wiring + * these to genesis, and refusing to start when the Falcon registry does not match the genesis {@code + * pqRegistryHash}, is a PRECONDITION of arming and is tracked as the fork-activation and registry + * work items. Until that lands, a non-default value here is a laboratory setting, not a deployment. */ public final class PqAnchorConfig { @@ -155,10 +154,9 @@ public final class PqAnchorConfig { *

    MEASURED 2026-08-07, and this is why the property exists. A Falcon-512 seal is 666 bytes. The * seal counts observed on a live seven-node run with the threshold at 4 were: 42 blocks with 4, 36 * with 5, 5 with 6. The rehearsal's median header of 3838 bytes is {@code (3838-525)/666 = 4.97} - * seals. Header bytes therefore scale with the seals actually attached: five seals is about 1.7 - * times what the same chain writes capped at K=3, and about seven times a seal-less header. The - * figure used before that day had been computed for a SINGLE seal, so it understated the cost by - * about 4.4x. + * seals. Per node per year, at ~165248 blocks/day: one seal 40.2 GB, three 120.5 GB, five 200.9 + * GB, seven 281.2 GB. The figure carried in our own documents until that day, 45.2 GB/year, is + * 1.13 seals: it had been computed for a single seal and was wrong by 4.4x. * *

    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 @@ -190,11 +188,10 @@ public final class PqAnchorConfig { * * and it is a knob, not an accident. * - *

    MEASURED 2026-08-07, at K=3 capped, 666 bytes a seal: the certificate cost falls in exact - * proportion to the interval, so every 10th block costs a tenth of the every-block figure, every - * 100th a hundredth, every 256th about a 250th. Against a ~523 ms block, an interval of 100 buys - * that hundredfold saving for a rewritable tail that grows from about half a second to about - * fifty-two seconds. Algorand ships the same shape at 1 in 256. + *

    MEASURED 2026-08-07, at K=3 capped, ~165248 blocks/day, 666 bytes a seal, per node per year: + * every block 120.5 GB; every 10th 12.1 GB; every 100th 1.2 GB; every 256th 0.5 GB. Against a + * ~523 ms block, an interval of 100 buys a hundredfold saving for a rewritable tail that grows + * from about half a second to about fifty-two seconds. Algorand ships the same shape at 1 in 256. * *

    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. @@ -225,14 +222,14 @@ public final class PqAnchorConfig { /** * 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"; /** - * MIN-SEALS FLOOR: the greppable name of the guard that refuses an armed anchor whose schedule - * never demands a single signature. Named, and not just a message, so that a check can ask whether - * the guard EXISTS rather than whether some prose happens to be present. + * D-147: the greppable name of the guard that refuses an armed anchor whose schedule never demands + * a single signature. Named, and not just a message, so that a check can ask whether the guard + * EXISTS rather than whether some prose happens to be present. */ 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 " + "hash chain makes each anchor protect everything BELOW it, so what stays rewritable " + "by an adversary holding every classical validator key is the TAIL since the last " - + "anchor: fork depth <= {} blocks. This is a DELIBERATE weakening bought for header " - + "size: the certificate cost falls to roughly 1/{} of the every-block figure.", + + "anchor: fork depth <= {} blocks. This is a DELIBERATE weakening bought for disk: " + + "at K=3 capped it is about {} GB per node per year instead of about 120.", iv, config.anchorBlock, iv, - iv); + String.format("%.1f", 120.5 / iv)); } if (config.maxSealsCarried().isPresent() && config.everActive()) { LOG.warn( @@ -575,7 +572,7 @@ public final class PqAnchorConfig { + "header cost and takes NOTHING from the quorum margin, which is decided by the " + "threshold a verifier demands, not by how many seals a proposer volunteers above " + "it. Measured 2026-08-07: uncapped, a K=3 chain at N=7 carries about five seals, " - + "which is 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.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 - * ARMED as a structure and completely toothless, for ever. + * D-147, THE MIN-SEALS FLOOR. A schedule whose effective K is ZERO at every height leaves the + * anchor ARMED as a structure and completely toothless, for ever. * *

    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 @@ -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 * PRESENT and of the shape {@code :0} reaches the same end state and went through unseen. * - *

    WHY THIS IS NOT THEORETICAL: it is the very shape a staged activation recommends, one that + *

    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, * to a truncated environment variable or a misplaced quote, what is left is {@code :0}. The * nodes start, every tool comes out green because each of them measures what was ASKED FOR and - * the ask is valid, and the threshold stays zero for ever. Not even a tool that compares the - * effective threshold against the requested one catches this: here both of them are zero. + * the ask is valid, and the threshold stays zero for ever. Not even ancora-prag-efectiv.sh + * catches this: it compares what came out against what was asked for, and here both are zero. * *

    The 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 @@ -1028,8 +1025,9 @@ public final class PqAnchorConfig { .append(expected) .append('\n') .append(" FIX correct BESU_OPTS on THIS node and restart ONLY this node\n") - .append(" WARNING if the same value is on all seven: restart one at a time,\n") - .append(" never in parallel. At quorum 5 of 7 you lose the chain.\n") + .append(" WARNING if the same value is on every node: restart one at a time,\n") + .append(" never in parallel. The chain stops as soon as more than f\n") + .append(" validators are down at once, whatever the set size is today.\n") .append(" EMERGENCY ") .append(PROPERTY_DISABLE) .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 * classical validator key can still rewrite is the TAIL since the last anchor. Therefore * {@code fork depth <= interval}. At ~523 ms a block, an interval of 100 is about 52 seconds of - * rewritable tail, against about half a second at interval 1, 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 * @return a copy of this configuration carrying the interval diff --git a/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorNotReadyException.java b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorNotReadyException.java index 7725600..2501920 100644 --- a/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorNotReadyException.java +++ b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorNotReadyException.java @@ -1,5 +1,5 @@ /* - * Copyright contributors to Besu / Aere Network. + * Copyright contributors to Besu / AERE Network. * * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at @@ -28,8 +28,8 @@ package org.hyperledger.besu.consensus.common.bft; *

    WHEN IT CAN HAPPEN. Only above the activation height, and only once the operator has raised K * past zero: the first stage is required to be K=0, so activation itself can never refuse. In * steady state the usual cause is a node that restarted and has not yet taken part in a commit, at - * most one proposer turn. The other cause, f validators withholding Falcon seals, is a measured - * exposure of its own and is the reason the validator set must grow to N>=9 before K is raised to + * most one proposer turn. The other cause, f validators withholding Falcon seals, is the measured + * 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. * *

    Callers on the consensus path must catch this and simply not propose. It carries the numbers a diff --git a/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorSyncModeGuard.java b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorSyncModeGuard.java index 73dd049..2dc16d8 100644 --- a/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorSyncModeGuard.java +++ b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorSyncModeGuard.java @@ -1,5 +1,5 @@ /* - * Copyright contributors to Besu / Aere Network. + * Copyright contributors to Besu / AERE Network. * * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at @@ -51,8 +51,8 @@ import org.slf4j.LoggerFactory; * remove. * *

    Inert when the anchor is not configured. 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 - * before on any chain where the anchor is not configured on any node. + * returns before it looks at the sync mode, so a binary carrying it behaves exactly as today on + * chain 2800 as it stands, where the anchor is not configured on any node. */ public final class PqAnchorSyncModeGuard { diff --git a/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorThresholdGuard.java b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorThresholdGuard.java index 6551df0..963c64b 100644 --- a/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorThresholdGuard.java +++ b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorThresholdGuard.java @@ -1,5 +1,5 @@ /* - * Copyright contributors to Besu / Aere Network. + * Copyright contributors to Besu / AERE Network. * * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at @@ -39,7 +39,7 @@ import org.slf4j.LoggerFactory; *

      *
    • {@code quorum(N)} is Besu's own {@link BftHelpers#calculateRequiredValidatorQuorum(int)}, * {@code ceil(2N/3)}. At N=7 that is {@code ceil(14/3) = 5}, so the highest configurable - * threshold at that set size is 4. + * threshold on chain 2800 as it stands is 4. *
    • {@code K = quorum} is a GUARANTEED HALT, and that is measured, not argued. A proposer * assembles its certificate out of the Falcon seals it heard on Commit messages, and the block * is IMPORTED at the quorum-th Commit; after the import {@code QbftController.consumeMessage} @@ -59,9 +59,9 @@ import org.slf4j.LoggerFactory; *
    * *

    What is a refusal and what is only a shout. Above {@code quorum - f} the schedule is - * still reachable but has no margin against f silent or keyless signers, which is a measured - * exposure of its own. That is a deliberate operator choice with a real cost, so it gets a loud - * WARN and the node starts. At or above {@code quorum} the schedule is not reachable at all, so it gets a + * still reachable but has no margin against f silent or keyless signers, which is the measured A10 + * and A13 exposure. That is a deliberate operator choice with a real cost, so it gets a loud WARN and + * the node starts. At or above {@code quorum} the schedule is not reachable at all, so it gets a * refusal. A guard that refused both would take the emergency ladder away; a guard that shouted for * both would be the log line this class exists to replace. * @@ -76,8 +76,8 @@ import org.slf4j.LoggerFactory; * count the validators" is not "the threshold is probably fine". * *

    Inert when the anchor is not armed. With no {@code aere.pq.anchorBlock}, or with the - * anchor emergency-disarmed, this method returns before it computes anything, so a binary carrying - * it behaves exactly as it did before on any chain where the anchor is armed on no node. + * anchor emergency-disarmed, this method returns before it computes anything, so a binary carrying it + * behaves exactly as today on chain 2800, where the anchor is armed on no node. */ 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. * - *

    One below the QBFT quorum. At N=7 this is 4. + *

    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. + * + *

    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 - * @return the highest configurable threshold K + * @return the highest configurable threshold K, {@code N - f} */ 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 maxConfigurable = quorum - 1; + final int maxConfigurable = maxConfigurableThreshold(validatorCount); final int f = byzantineBudget(validatorCount); final int noMarginAbove = quorum - f; @@ -169,7 +182,7 @@ public final class PqAnchorThresholdGuard { highest = effective; highestAt = at; } - if (effective >= quorum && fatalHeight < 0L) { + if (effective > validatorCount - f && fatalHeight < 0L) { fatalHeight = at; fatalThreshold = effective; } @@ -187,21 +200,18 @@ public final class PqAnchorThresholdGuard { + validatorCount + " validators this node is starting into is " + 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 - + ". WHAT THIS MEANS: a proposer builds its certificate out of the Falcon seals it " - + "heard on Commit messages, and the block is imported at the quorum-th Commit; every " - + "Commit arriving after that import is discarded as targeting a height not above the " - + "chain head, so a proposer can gather at most quorum seals at ANY validator-set " - + "size. Measured on an isolated N=4 network with quorum 3: k=3 on every header above " - + "the activation height, never 4, with all four nodes keyed and healthy. A threshold " - + "of " + + " = N - f. WHAT THIS MEANS (doctrine revised 2026-08-20 for D-227 late-seal " + + "salvage): the seal cache accumulates seals from every ALIVE keyed validator, " + + "measured on mainnet 2800 as 8-9 seals per certificate at N=9 across 5,400 anchors. " + + "But with f validators Byzantine or down - the design's own fault budget - at most " + + "N - f seals can ever exist, so a threshold of " + fatalThreshold - + " therefore requires that ALL of the first " - + quorum - + " Commits carry a valid and eligible Falcon seal; one validator without a key among " - + "them, or one seal that does not verify, and no proposer proposes again. That is a " - + "halt, not a degradation, and it starts at height " + + " makes anchor blocks unreachable inside the tolerated fault model. That is a halt " + + "bought by configuration, and it starts at height " + fatalHeight + ". WHAT TO DO: lower the step to at most " + 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) { LOG.warn( "AERE PQ ANCHOR: threshold guard PASSED but the schedule has NO MARGIN. K reaches {} at " diff --git a/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorV2.java b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorV2.java new file mode 100644 index 0000000..1193143 --- /dev/null +++ b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorV2.java @@ -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 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 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 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 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 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 seals, final byte wireId) { + return (int) + seals.stream() + .filter(s -> s.getSchemeWireId() == wireId) + .mapToInt(SchemeSeal::getValidatorIndex) + .distinct() + .count(); + } + + private static void requireCanonical(final List 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; + } + } +} diff --git a/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqRegistryBinding.java b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqRegistryBinding.java index 5e3a832..780bf83 100644 --- a/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqRegistryBinding.java +++ b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqRegistryBinding.java @@ -34,15 +34,14 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** - * AERE REGISTRY BINDING: bind each registry row's FALCON PUBLIC KEY to the VALIDATOR ADDRESS it - * sits next to. + * AERE D-146: bind each registry row's FALCON PUBLIC KEY to the VALIDATOR ADDRESS it sits next to. * *

    The defect, measured on the real code path on 2026-08-06

    * *

    {@link PqRegistryHash} makes the registry a chain-committed object: two nodes cannot hold * different files without one of them refusing to start. It says so itself, in its own class * javadoc: "It says nothing about whether any validator actually holds the private key matching its - * registered public key." That sentence is the whole of the defect this class closes. + * registered public key." That sentence is the whole of D-146. * *

    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 @@ -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: * at the key ceremony the registry writer holds every Falcon secret, so it can sign a * possession proof for key 0 sitting under validator 1's address. Anyone who claims a Falcon - * proof-of-possession repairs this binding defect is wrong, and the probe measures it. + * proof-of-possession repairs D-146 is wrong, and the probe measures it. *

  • CLAIM, an ECDSA signature by the row's own VALIDATOR key. This is the half that cuts. The * registry writer cannot forge it without validator i's consensus key, so a key cannot be * moved under another validator's address, indices cannot be swapped, and a key cannot appear @@ -145,7 +144,7 @@ import org.slf4j.LoggerFactory; *

    What this does NOT defend against, stated plainly

    * *
      - *
    • NOT the holder of the vault. All seven validator ECDSA keys live in one place; whoever has + *
    • 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 * "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. @@ -300,7 +299,7 @@ public final class PqRegistryBinding { verifier.init(false, pub); return verifier.verifySignature(digest.toArray(), signature); } 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; } } @@ -321,7 +320,7 @@ public final class PqRegistryBinding { SignatureAlgorithmFactory.getInstance().decodeSignature(Bytes.wrap(signature)); return Util.signatureToAddress(s, Hash.wrap(digest)); } catch (final RuntimeException e) { - LOG.debug("AERE PQC REGISTRY-BINDING: claim recovery threw: {}", e.toString()); + LOG.debug("AERE PQC D-146: claim recovery threw: {}", e.toString()); return null; } } @@ -351,7 +350,7 @@ public final class PqRegistryBinding { if (!registry.addressBound()) { throw new PqRegistryHash.RegistryConfigException( "AERE-PQC-REG-BIND-06", - "AERE PQC REGISTRY-BINDING: registry '" + "AERE PQC D-146: registry '" + source + "' carries binding proofs but is NOT address-bound. A proof binds a Falcon key to a " + "validator ADDRESS; with no addresses there is nothing to bind to. Refusing " @@ -371,7 +370,7 @@ public final class PqRegistryBinding { if (pop == null || pop.length == 0) { throw new PqRegistryHash.RegistryConfigException( "AERE-PQC-REG-BIND-01", - "AERE PQC REGISTRY-BINDING: registry '" + "AERE PQC D-146: registry '" + source + "' index " + e.index() @@ -382,7 +381,7 @@ public final class PqRegistryBinding { if (claim == null || claim.length == 0) { throw new PqRegistryHash.RegistryConfigException( "AERE-PQC-REG-BIND-02", - "AERE PQC REGISTRY-BINDING: registry '" + "AERE PQC D-146: registry '" + source + "' index " + e.index() @@ -395,7 +394,7 @@ public final class PqRegistryBinding { if (!verifyPossession(pk, popDigest, pop)) { throw new PqRegistryHash.RegistryConfigException( "AERE-PQC-REG-BIND-03", - "AERE PQC REGISTRY-BINDING: registry '" + "AERE PQC D-146: registry '" + source + "' index " + e.index() @@ -417,7 +416,7 @@ public final class PqRegistryBinding { if (recovered == null) { throw new PqRegistryHash.RegistryConfigException( "AERE-PQC-REG-BIND-05", - "AERE PQC REGISTRY-BINDING: registry '" + "AERE PQC D-146: registry '" + source + "' index " + e.index() @@ -431,7 +430,7 @@ public final class PqRegistryBinding { if (!recovered.equals(bound)) { throw new PqRegistryHash.RegistryConfigException( "AERE-PQC-REG-BIND-04", - "AERE PQC REGISTRY-BINDING: registry '" + "AERE PQC D-146: registry '" + source + "' index " + e.index() @@ -439,7 +438,7 @@ public final class PqRegistryBinding { + bound + " but its claim was signed by " + 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 " + "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 " @@ -450,8 +449,8 @@ public final class PqRegistryBinding { } } LOG.info( - "AERE PQC REGISTRY-BINDING: registry '{}' - all {} rows carry a verified Falcon possession " - + "proof and a verified validator claim (chainId={}, bindHeight={}).", + "AERE PQC D-146: registry '{}' - all {} rows carry a verified Falcon possession proof and a " + + "verified validator claim (chainId={}, bindHeight={}).", source, count, chainId, diff --git a/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqRegistryHash.java b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqRegistryHash.java index 6b28572..01ee406 100644 --- a/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqRegistryHash.java +++ b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqRegistryHash.java @@ -34,7 +34,7 @@ import org.slf4j.Logger; 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. * *

      THE DEFECT THIS EXISTS TO CLOSE. Measured by reading {@code FalconSealSupport}: the registry * that answers "which Falcon public key is validator index i" can be loaded from a plain local @@ -81,7 +81,7 @@ public final class PqRegistryHash { public static final String DOMAIN_V1 = "AERE-PQ-REGISTRY-1"; /** - * Domain tag of the canonical v2 pre-image: the same registry PLUS the 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 * a downgrade that strips the proofs cannot satisfy a schedule entry that was written for v2. */ @@ -126,8 +126,8 @@ public final class PqRegistryHash { private final int index; private final byte[] address; // 20 bytes, or null when the source is not address-bound private final byte[] publicKey; - private final byte[] possessionProof; // row binding proof, or null in a v1 registry - private final byte[] claimProof; // 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; // D-146, or null in a v1 registry Entry(final int index, final byte[] address, final byte[] publicKey) { this(index, address, publicKey, null, null); @@ -174,7 +174,7 @@ public final class PqRegistryHash { } /** - * 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. * * @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. - * This is the half a registry writer cannot forge, and therefore the half that closes both - * rebinding a row to another validator's address and swapping two rows. + * D-146. The ECDSA signature by this row's own VALIDATOR key over the binding pre-image. This is + * the half a registry writer cannot forge, and therefore the half that closes T3 and T6. * * @return the claim proof, or null in a v1 registry */ @@ -209,9 +208,9 @@ public final class PqRegistryHash { private final String sourcePath; private final List entries; // ascending index, contiguous from 0 private final boolean addressBound; - private final boolean proofBound; // every row carries both binding proofs - private final long declaredChainId; // the chainId the proofs were signed over - private final long bindHeight; // the activation height the proofs were signed over + private final boolean proofBound; // D-146: every row carries both binding proofs + private final long declaredChainId; // D-146: the chainId the proofs were signed over + private final long bindHeight; // D-146: the activation height the proofs were signed over Registry( 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. * * @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 * 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. * * @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. * *

      @@ -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
          * fields deleted, it would hash the same, satisfy the schedule, and load without ever verifying
          * anything. With them inside, stripping a proof is a different registry with a different hash and
      -   * the existing 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
          * 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 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
          * 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.
          *
          * 

      The shape is deliberately the same as {@code AERE-PQC-REG-ARM-01}, which already refuses to * arm over a registry with no validator addresses at all, and for the same reason: arming is the * last moment at which the registry format can still be changed. The anchor contract is immutable - * once written, so a fleet armed over unbound rows carries 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. * *

      WIRED 2026-08-06. It is called from {@code * FalconSealSupport.requireRegistryBindingProofsOrAbort()}, in the constructor, immediately after @@ -484,7 +483,7 @@ public final class PqRegistryHash { } throw new RegistryConfigException( "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() + "'") + ", 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 " @@ -569,7 +568,7 @@ public final class PqRegistryHash { } catch (final IOException e) { throw new RegistryConfigException( "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 + "': " + e @@ -582,7 +581,7 @@ public final class PqRegistryHash { if (countRaw == null) { throw new RegistryConfigException( "AERE-PQC-REG-LOAD-02", - "AERE PQC GENESIS-BINDING: the Falcon registry '" + "AERE PQC A8: the Falcon registry '" + path + "' has no 'count' property. Refusing to continue (fail-closed): without a declared " + "count there is no way to tell a complete registry from one that lost its last " @@ -591,7 +590,7 @@ public final class PqRegistryHash { } 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 // 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. @@ -628,7 +627,7 @@ public final class PqRegistryHash { if (a.length != 20) { throw new RegistryConfigException( "AERE-PQC-REG-LOAD-03", - "AERE PQC GENESIS-BINDING: registry '" + "AERE PQC A8: registry '" + path + "' entry " + i @@ -679,7 +678,7 @@ public final class PqRegistryHash { if (cfg.isMissingNode()) { throw new RegistryConfigException( "AERE-PQC-REG-LOAD-04", - "AERE PQC GENESIS-BINDING: genesis '" + "AERE PQC A8: genesis '" + genesisPath + "' has no config.aereFalconRegistry manifest. Refusing to continue (fail-closed)."); } @@ -700,7 +699,7 @@ public final class PqRegistryHash { } catch (final IOException e) { throw new RegistryConfigException( "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; 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")) { throw new RegistryConfigException( "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); @@ -749,7 +748,7 @@ public final class PqRegistryHash { } catch (final NumberFormatException e) { throw new RegistryConfigException( "AERE-PQC-REG-LOAD-18", - "AERE PQC GENESIS-BINDING: manifest '" + "AERE PQC A8: manifest '" + source + "' has the unrecognised field '" + n @@ -762,7 +761,7 @@ public final class PqRegistryHash { if (!strays.isEmpty()) { throw new RegistryConfigException( "AERE-PQC-REG-LOAD-09", - "AERE PQC GENESIS-BINDING: manifest '" + "AERE PQC A8: manifest '" + source + "' declares count=" + count @@ -795,7 +794,7 @@ public final class PqRegistryHash { if (e == null) { throw new RegistryConfigException( "AERE-PQC-REG-LOAD-06", - "AERE PQC GENESIS-BINDING: manifest '" + "AERE PQC A8: manifest '" + source + "' declares count=" + count @@ -809,7 +808,7 @@ public final class PqRegistryHash { if (a == null || !a.isTextual() || k == null || !k.isTextual()) { throw new RegistryConfigException( "AERE-PQC-REG-LOAD-07", - "AERE PQC GENESIS-BINDING: manifest '" + "AERE PQC A8: manifest '" + source + "' entry " + i @@ -819,7 +818,7 @@ public final class PqRegistryHash { if (addr.length != 20) { throw new RegistryConfigException( "AERE-PQC-REG-LOAD-03", - "AERE PQC GENESIS-BINDING: manifest '" + "AERE PQC A8: manifest '" + source + "' entry " + i @@ -842,7 +841,7 @@ public final class PqRegistryHash { } else { throw new RegistryConfigException( "AERE-PQC-REG-LOAD-08", - "AERE PQC GENESIS-BINDING: manifest '" + "AERE PQC A8: manifest '" + source + "' entry " + 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 * note. This file had been type-checked standalone with {@code javac -Xlint:all} and reported * clean; that is a weaker statement than it sounds, and the difference is the whole reason the - * wiring had to be compiled in the real tree before 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. */ private static Registry assemble( @@ -877,7 +876,7 @@ public final class PqRegistryHash { if (pks.size() != count) { throw new RegistryConfigException( "AERE-PQC-REG-LOAD-09", - "AERE PQC GENESIS-BINDING: registry '" + "AERE PQC A8: registry '" + source + "' declares count=" + count @@ -894,7 +893,7 @@ public final class PqRegistryHash { if (!pks.containsKey(i)) { throw new RegistryConfigException( "AERE-PQC-REG-LOAD-10", - "AERE PQC GENESIS-BINDING: registry '" + "AERE PQC A8: registry '" + source + "' is missing index " + i @@ -907,7 +906,7 @@ public final class PqRegistryHash { if (pks.get(i).length == 0) { throw new RegistryConfigException( "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)."); } } @@ -920,7 +919,7 @@ public final class PqRegistryHash { if (!addrs.containsKey(i)) { throw new RegistryConfigException( "AERE-PQC-REG-LOAD-12", - "AERE PQC GENESIS-BINDING: registry '" + "AERE PQC A8: registry '" + source + "' binds addresses for " + addrs.keySet() @@ -932,7 +931,7 @@ public final class PqRegistryHash { } else { throw new RegistryConfigException( "AERE-PQC-REG-LOAD-12", - "AERE PQC GENESIS-BINDING: registry '" + "AERE PQC A8: registry '" + source + "' is MIXED: " + 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. // // 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) { throw new RegistryConfigException( "AERE-PQC-REG-LOAD-19", - "AERE PQC ROW-BINDING: registry '" + "AERE PQC D-146: registry '" + source + "' carries the SAME Falcon public key at index " + first @@ -987,7 +986,7 @@ public final class PqRegistryHash { if (first != null) { throw new RegistryConfigException( "AERE-PQC-REG-LOAD-20", - "AERE PQC ROW-BINDING: registry '" + "AERE PQC D-146: registry '" + source + "' binds the SAME validator address 0x" + 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. final boolean anyProof = !pops.isEmpty() || !claims.isEmpty(); final boolean proofBound; @@ -1010,7 +1009,7 @@ public final class PqRegistryHash { if (pops.size() != count || claims.size() != count) { throw new RegistryConfigException( "AERE-PQC-REG-LOAD-21", - "AERE PQC ROW-BINDING: registry '" + "AERE PQC D-146: registry '" + source + "' declares count=" + count @@ -1025,7 +1024,7 @@ public final class PqRegistryHash { if (declaredChainId < 0 || bindHeight < 0) { throw new RegistryConfigException( "AERE-PQC-REG-LOAD-22", - "AERE PQC ROW-BINDING: registry '" + "AERE PQC D-146: registry '" + source + "' carries binding proofs but does not declare both 'chainId' and 'bindHeight'. " + "Both are inside the signed message, so without them the proofs cannot even be " @@ -1047,7 +1046,7 @@ public final class PqRegistryHash { proofBound ? claims.get(i) : null)); if (pks.get(i).length != FALCON_512_PK_LENGTH) { 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 " + "length-prefixed and handles any length), but it is almost certainly the wrong " + "file or a truncated copy.", @@ -1059,18 +1058,18 @@ public final class PqRegistryHash { } final Registry assembled = 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) // per process start with zero cost per block. PqRegistryBinding.verifyOrThrow(assembled); if (!proofBound) { 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 " + "wrote this file decided who every seal is credited to. Measured 2026-08-06 on the " + "real verification path: swapping two rows produces an ACCEPTED header with no " - + "duplicate key and no duplicate address. This is the row-binding defect, and it " - + "is not closed on this node.", + + "duplicate key and no duplicate address. This is D-146 and it is not closed on this " + + "node.", source, count); } @@ -1206,7 +1205,7 @@ public final class PqRegistryHash { if (node.isEmpty()) { throw new RegistryConfigException( "AERE-PQC-REG-SCHED-01", - "AERE PQC GENESIS-BINDING: " + "AERE PQC A8: " + source + " is an EMPTY array. Refusing to start (fail-closed): an empty schedule is not " + "the same statement as an absent one, and guessing which was meant is exactly how " @@ -1218,7 +1217,7 @@ public final class PqRegistryHash { if (!e.isObject() || !e.has("block") || !e.has("hash")) { throw new RegistryConfigException( "AERE-PQC-REG-SCHED-02", - "AERE PQC GENESIS-BINDING: " + "AERE PQC A8: " + source + " entry " + e @@ -1228,7 +1227,7 @@ public final class PqRegistryHash { if (block < 0) { throw new RegistryConfigException( "AERE-PQC-REG-SCHED-03", - "AERE PQC GENESIS-BINDING: " + "AERE PQC A8: " + source + " has a negative or unparseable block in " + e @@ -1237,7 +1236,7 @@ public final class PqRegistryHash { if (block <= previous) { throw new RegistryConfigException( "AERE-PQC-REG-SCHED-04", - "AERE PQC GENESIS-BINDING: " + "AERE PQC A8: " + source + " blocks are not STRICTLY INCREASING (" + previous @@ -1253,7 +1252,7 @@ public final class PqRegistryHash { } else { throw new RegistryConfigException( "AERE-PQC-REG-SCHED-05", - "AERE PQC GENESIS-BINDING: " + "AERE PQC A8: " + source + " must be a 0x-prefixed 32-byte hash or an array of {block, hash}; found " + 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. * *

      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, @@ -1361,7 +1360,7 @@ public final class PqRegistryHash { private final List entryBlocks; // ascending, one per COVERED schedule entry private final List entryRegistries; // parallel to entryBlocks private final List uncovered; // schedule entry blocks with no matching registry - private final List misbound; // signed height did not match the scheduled one + private final List misbound; // D-B: hash matched, signed height did not RegistrySet( final List 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 */ @@ -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. * *

      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 @@ -1500,8 +1499,8 @@ public final class PqRegistryHash { if (!hashFor(r, chainId).equalsIgnoreCase(e.hash)) { continue; } - // AERE SIGNED-HEIGHT CHECK (2026-08-06). THE LINK THAT WAS NEVER DRAWN. Both numbers have been in this - // lexical scope since the height schedule was written and they were never put on the same expression. + // AERE D-B (2026-08-06). THE LINK THAT WAS NEVER DRAWN. Both numbers have been in this + // lexical scope since D-081 was written and they were never put on the same expression. // // 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 @@ -1513,9 +1512,9 @@ public final class PqRegistryHash { // The node and the tool disagreed and nothing put them face to face. // // WHAT THAT BUYS AN OPERATOR WHO IS NOT SUPPOSED TO HAVE IT: moving the activation day - // costs 14 fresh signatures if this is checked, and ZERO if it is not. The seven - // validators' agreement on a height is only an agreement if something refuses the heights - // they did not sign. + // costs two fresh signatures per validator if this is checked, and ZERO if it is not. + // The validators' agreement on a height is only an agreement if something refuses the + // heights they did not sign. if (r.proofBound() && r.bindHeight() != e.block) { misbound.add(new Misbound(e.block, e.hash, r.sourcePath(), r.bindHeight())); 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. * * @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) {} /** - * 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 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 "". * * @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. * *

      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 @@ -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. * *

      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 @@ -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. * - *

      WHY THIS OVERLOAD HAD TO EXIST, and it is not tidiness. The height schedule gave a node a HISTORY of + *

      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 * 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 @@ -1741,19 +1740,19 @@ public final class PqRegistryHash { final long chainHeadNumber, 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 // internally consistent, just for another chain - and would otherwise pass. Fail-closed here, // where the node's real chain id is known and the file's is not yet trusted. // - // AERE 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 // an interval of history - and before this it was never asked. for (final Registry r : set.loaded()) { if (r.proofBound() && r.declaredChainId() != chainId) { throw new RegistryConfigException( "AERE-PQC-REG-BIND-07", - "AERE PQC ROW-BINDING: registry '" + "AERE PQC D-146: registry '" + r.sourcePath() + "' declares chainId=" + r.declaredChainId() @@ -1768,7 +1767,7 @@ public final class PqRegistryHash { if (!schedule.enforced()) { 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, " + "address-bound={}), canonical hash 0x{}. Two nodes holding DIFFERENT registry files " + "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 ? 0 : registry.count(), registry != null && registry.addressBound(), - // GENESIS BINDING, measured on a fleet of seven on 2026-08-06: this printed hashV1 next - // to the text "put this in config.pqRegistryHash", while THE GATE compares hashFor, - // which for a registry carrying proofs is hashV2. With the printed value put into - // genesis, all seven nodes start, all seven report the registry loaded, and THE CHAIN - // STOPS AT H-1. The guard shouts NOT CORRECTLY STAGED, so it is not a silent halt, but - // the operator who follows the node's own instruction halts the fleet. A wrong - // instruction is more dangerous than no instruction at all. + // A8, measured on a fleet of seven on 2026-08-06: this printed hashV1 next to the text + // "put this in config.pqRegistryHash", while THE GATE compares hashFor, which for a + // registry carrying proofs is hashV2. With the printed value put into genesis, all seven + // nodes start, all seven report the registry loaded, and THE CHAIN STOPS AT H-1. The + // guard shouts NOT CORRECTLY STAGED, so it is not a silent halt, but the operator who + // follows the node's own instruction halts the fleet. A wrong instruction is more + // dangerous than no instruction at all. registry == null ? "(no registry)" : hashFor(registry, chainId)); 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 // exist, schedule.enforced() is false, and the return above is the first executable statement // this guard reaches. Nothing new is ever put above it. @@ -1815,7 +1814,7 @@ public final class PqRegistryHash { } throw new RegistryConfigException( "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" + " FIELD: 'bindHeight', inside the registry file, versus 'block' of the matching " + "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. // Measured. // Old registry: starts, cannot advance. New registry: cannot start. There was no third file, - // and the only exit measured was to set the emergency bypass on every node, i.e. to switch - // the genesis binding off across the whole fleet in order to cross a PLANNED rotation. + // and the only exit measured was to set the emergency bypass on every node, i.e. to switch A8 + // off across the whole fleet in order to cross a PLANNED rotation. // // chainHead + 1 is the question the node can actually act on: the only header it will be // offered next is chainHead + 1, and PqRegistryBindingRule judges that header against the entry @@ -1873,13 +1872,13 @@ public final class PqRegistryHash { if (active.isEmpty()) { 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. final Registry staged = set.forEntryBlock(first.block()); final String computed = registry == null ? null : hashFor(registry, chainId); if (staged != null) { 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 ({}, {} " + "entries) ALREADY MATCHES the hash required from block {}: 0x{}. This node is " + "correctly staged for the activation.", @@ -1891,7 +1890,7 @@ public final class PqRegistryHash { hashFor(staged, chainId)); } else { 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 " + "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 " @@ -1915,7 +1914,7 @@ public final class PqRegistryHash { if (set.count() == 0) { throw new RegistryConfigException( "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" + " 0x" + required.hash() @@ -1954,14 +1953,14 @@ public final class PqRegistryHash { } 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 // alone, and after the first rotation the primary is by definition NOT the file that answers for // the interval below the rotation. final Registry bound = set.forEntryBlock(required.block()); if (bound != null) { LOG.info( - "AERE PQC 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 " + "config.pqRegistryHash, read from [{}]. Chain head {}, chainId {}. This node holds " + "{} registry file(s) in total. {}", @@ -1971,7 +1970,7 @@ public final class PqRegistryHash { bound.addressBound(), hashFor(bound, chainId), 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 // binding is satisfied without saying what it was read from asserts more than it knows. schedule.source(), @@ -2001,7 +2000,7 @@ public final class PqRegistryHash { throw new RegistryConfigException( "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" + " EXPECTED hash: 0x" + 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 * it is the wrong one" from "I gave it four and none covers this height", which are different * mistakes with different fixes. @@ -2097,12 +2096,11 @@ public final class PqRegistryHash { b.append(" entries : ").append(registry.count()).append('\n'); b.append(" addressBound: ").append(registry.addressBound()).append('\n'); b.append(" format: ") - .append(registry.proofBound() ? "v2, binding proofs present" : "v1, NO binding proofs") + .append(registry.proofBound() ? "v2, D-146 binding proofs present" : "v1, NO binding proofs") .append('\n'); - // GENESIS BINDING: the report printed both v1 and v2 without saying WHICH one goes into - // genesis, and whoever took the last value off the screen took v1 and halted the fleet at - // H-1. The one that matters is now named explicitly, and it is the very one the gate - // compares: hashFor. + // A8: the report printed both v1 and v2 without saying WHICH one goes into genesis, and + // whoever took the last value off the screen took v1 and halted the fleet at H-1. The one + // that matters is now named explicitly, and it is the very one the gate compares: hashFor. b.append(" >>> FOR config.pqRegistryHash: 0x") .append(hashFor(registry, chainId)) .append(" <<< this one, and only this one\n"); @@ -2147,7 +2145,7 @@ public final class PqRegistryHash { } catch (final IOException e) { throw new RegistryConfigException( "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 + "': " + e @@ -2165,7 +2163,7 @@ public final class PqRegistryHash { } catch (final NumberFormatException e) { throw new RegistryConfigException( "AERE-PQC-REG-LOAD-14", - "AERE PQC GENESIS-BINDING: registry '" + "AERE PQC A8: registry '" + source + "' has the unrecognised key '" + 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, * because a default is how a threshold quietly becomes zero. */ @@ -2192,7 +2190,7 @@ public final class PqRegistryHash { } catch (final NumberFormatException e) { throw new RegistryConfigException( "AERE-PQC-REG-LOAD-23", - "AERE PQC ROW-BINDING: registry '" + "AERE PQC D-146: registry '" + source + "' has " + what @@ -2203,7 +2201,7 @@ public final class PqRegistryHash { if (v < 0) { throw new RegistryConfigException( "AERE-PQC-REG-LOAD-23", - "AERE PQC ROW-BINDING: registry '" + "AERE PQC D-146: registry '" + source + "' has a negative " + what @@ -2224,7 +2222,7 @@ public final class PqRegistryHash { } catch (final NumberFormatException e) { throw new RegistryConfigException( "AERE-PQC-REG-LOAD-15", - "AERE PQC GENESIS-BINDING: registry '" + "AERE PQC A8: registry '" + source + "' has a malformed " + what @@ -2249,7 +2247,7 @@ public final class PqRegistryHash { if (s.isEmpty() || (s.length() & 1) == 1) { throw new RegistryConfigException( "AERE-PQC-REG-LOAD-16", - "AERE PQC GENESIS-BINDING: registry '" + "AERE PQC A8: registry '" + source + "' " + what @@ -2267,7 +2265,7 @@ public final class PqRegistryHash { if (hi < 0 || lo < 0) { throw new RegistryConfigException( "AERE-PQC-REG-LOAD-17", - "AERE PQC GENESIS-BINDING: registry '" + "AERE PQC A8: registry '" + source + "' " + what @@ -2289,7 +2287,7 @@ public final class PqRegistryHash { if (s.length() != 64) { throw new RegistryConfigException( "AERE-PQC-REG-SCHED-06", - "AERE PQC GENESIS-BINDING: " + "AERE PQC A8: " + source + " carries the hash '" + raw @@ -2301,7 +2299,7 @@ public final class PqRegistryHash { if (Character.digit(s.charAt(i), 16) < 0) { throw new RegistryConfigException( "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; diff --git a/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqRegistryHashTool.java b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqRegistryHashTool.java index e5d2675..14071c5 100644 --- a/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqRegistryHashTool.java +++ b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqRegistryHashTool.java @@ -1,5 +1,5 @@ /* - * Copyright contributors to Besu / Aere Network. + * Copyright contributors to Besu / AERE Network. * * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at @@ -23,8 +23,8 @@ import java.util.Map; import java.util.Optional; /** - * REGISTRY SCHEDULE TOOL. The tool that writes and checks the two configuration values without - * which the height-indexed registry repair changes nothing. + * D-145. The tool that writes and checks the two configuration values without which the D2 repair + * changes nothing. * *

      WHY IT LIVES IN THE CONSENSUS MODULE AND NOT IN A SCRIPT. The value being computed is a * keccak digest over a domain-separated, length-prefixed, chain-bound pre-image, and the node @@ -36,8 +36,8 @@ import java.util.Optional; * *

        *   java -cp 'besu/lib/*' org.hyperledger.besu.consensus.common.bft.PqRegistryHashTool \
      - *        verify --chain-id 2800 --genesis <config-dir>/genesis.json \
      - *        --history <config-dir>/falcon/registry-epoca-0.properties
      + *        verify --chain-id 2800 --genesis ./genesis-2800.json \
      + *        --history ./falcon/registry-epoch-0.properties
        * 
      * *

      THREE VERBS. @@ -117,13 +117,13 @@ public final class PqRegistryHashTool { System.out.println("chainId=" + chainId + " (goes INTO the pre-image; 2800 and 442807 give different hashes for the same registry)"); for (final Path p : files) { final PqRegistryHash.Registry r = PqRegistryHash.loadAuto(p); - // AERE CANONICAL FINGERPRINT (2026-08-06): hashFor, not hashV1. For a registry that carries - // binding proofs, hashV1 is a number NOTHING in the node ever compares against: the guard - // compares hashFor, that is hashV2. The same mistake, in the `generate` verb below, writes - // into genesis a hash the node will never recognise, and then all seven start and the chain - // stops at H-1. On top of that, hashV1 does NOT tell two rotation epochs of the same fleet - // apart, because the bind height does not enter the v1 pre-image; so it cannot serve even as - // an epoch identifier for diagnostics. + // AERE D-C (2026-08-06): hashFor, not hashV1. For a registry that carries binding proofs, + // hashV1 is a number NOTHING in the node ever compares against: the guard compares hashFor, + // that is hashV2. The same mistake, in the `generate` verb below, writes into genesis a hash + // the node will never recognise, and then all seven start and the chain stops at H-1. + // On top of that, hashV1 does NOT tell two rotation epochs of the same fleet apart, because the + // bind height does not enter the v1 pre-image; so it cannot serve even as an epoch identifier + // for diagnostics. System.out.println( "0x" + PqRegistryHash.hashFor(r, chainId) @@ -146,8 +146,8 @@ public final class PqRegistryHashTool { final String armingHeightRaw = o.get("arming-height"); if (armingHeightRaw == null) { System.out.println("NOT MEASURED: --arming-height is missing. The first entry of the"); - System.out.println(" schedule must be EXACTLY at aere.pq.anchorBlock: a later"); - System.out.println(" first entry leaves the arming height with no scheduled registry."); + System.out.println(" schedule must be EXACTLY at aere.pq.anchorBlock; see case D"); + System.out.println(" of dovezi-d2-2026-08-06."); return 2; } final long h = Long.parseLong(armingHeightRaw); @@ -180,7 +180,7 @@ public final class PqRegistryHashTool { // The fragment LOOKS fine and CANNOT BE USED: genesis is read with Jackson without // ALLOW_COMMENTS, so a node handed one of those refuses to start with // [AERE-PQC-REG-LOAD-13] "Unexpected character ('/')", and it then refuses EVERY header from - // the arming height upwards. Measured 2026-08-06, 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 #. final StringBuilder json = new StringBuilder(); json.append(" \"pqRegistryHash\": [\n"); @@ -190,17 +190,17 @@ public final class PqRegistryHashTool { final long b = inaltimi.get(i); final Path p = epoci.get(b); final PqRegistryHash.Registry reg = PqRegistryHash.loadAuto(p); - // AERE CANONICAL FINGERPRINT (2026-08-06). THIS IS THE DANGEROUS VERB: what is printed here - // gets pasted into genesis, and genesis is the document all seven nodes hold identical. - // hashV1 next to a registry that carries proofs writes into genesis a number the node's guard - // (hashFor) never produces, so all seven nodes start, all report the registry loaded, and the - // chain stops at H-1. A wrong indication is more dangerous than a missing one. + // AERE D-C (2026-08-06). THIS IS THE DANGEROUS VERB: what is printed here gets pasted into + // genesis, and genesis is the document all seven nodes hold identical. hashV1 next to a + // registry that carries proofs writes into genesis a number the node's guard (hashFor) never + // produces, so all seven nodes start, all report the registry loaded, and the chain stops at + // H-1. A wrong indication is more dangerous than a missing one. 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 - // accepts. Since 2026-08-06 the node refuses to start (AERE-PQC-REG-BIND-08) on a registry - // forced in at a height its proofs did not sign. If the tool printed that recipe, it would - // manufacture exactly the configuration the node rejects, and it would do so in a file that - // reaches all seven at once. + // AERE D-B (2026-08-06). The recipe we print has to be the one the NEW code accepts. Since + // 2026-08-06 the node refuses to start (AERE-PQC-REG-BIND-08) on a registry forced in at a + // height its proofs did not sign. If the tool printed that recipe, it would manufacture + // exactly the configuration the node rejects, and it would do so in a file that reaches all + // seven at once. if (reg.proofBound() && reg.bindHeight() != b) { System.out.println( "RED: registry " @@ -271,10 +271,10 @@ public final class PqRegistryHashTool { System.out.println( "RED: " + genesis - + " does not carry config.pqRegistryHash. The height-indexed registry schedule " - + "exists, it is complete, and it is OFF. A node armed without a schedule falls back " - + "on TODAY's registry and says ACCEPTED for a header it has bound to no " - + "height."); + + " does not carry config.pqRegistryHash. This is D-145 exactly as it was measured: " + + "the machinery exists, it is complete, and it is OFF. A node armed without a " + + "schedule falls back on TODAY's registry and says ACCEPTED for a header it has " + + "bound to no height (case D of dovezi-d2-2026-08-06)."); return 1; } @@ -311,10 +311,9 @@ public final class PqRegistryHashTool { } System.out.println("registries held = " + set.count()); for (final Path p : history) { - // AERE CANONICAL FINGERPRINT: hashFor. This line sits immediately under the list of scheduled - // epochs printed with their hashes; two numbers laid one under the other so that they get - // compared by eye, and computed with two different functions, are a comparison that can never - // match. + // AERE D-C: hashFor. This line sits immediately under the list of scheduled epochs printed + // with their hashes; two numbers laid one under the other so that they get compared by eye, + // and computed with two different functions, are a comparison that can never match. final PqRegistryHash.Registry r = PqRegistryHash.loadAuto(p); System.out.println( " " @@ -325,9 +324,9 @@ public final class PqRegistryHashTool { } final String armareRaw = o.get("arming-height"); int rc = 0; - // AERE SIGNED-HEIGHT CHECK: the registries that reproduce the required hash and did NOT sign - // that height. This is exactly what the node now refuses to start on; until 2026-08-06 the node - // started and the tool said nothing. + // AERE D-B: the registries that reproduce the required hash and did NOT sign that height. This + // is exactly what the node now refuses to start on; until 2026-08-06 the node started and the + // tool said nothing. for (final PqRegistryHash.Misbound m : set.misbound()) { System.out.println( "RED: " @@ -341,11 +340,11 @@ public final class PqRegistryHashTool { } if (armareRaw != null) { final long h = Long.parseLong(armareRaw); - final long prima = schedule.entries().get(0).block(); - if (prima != h) { + final long first = schedule.entries().get(0).block(); + if (first != h) { System.out.println( "RED: the first entry of the schedule is at " - + prima + + first + ", while aere.pq.anchorBlock is " + h + ". 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 files(final Map o, final String cheie) { + private static List files(final Map o, final String key) { final List out = new ArrayList<>(); - for (final String s : list(o.get(cheie))) { + for (final String s : list(o.get(key))) { out.add(Path.of(s)); } return out; @@ -469,7 +468,7 @@ public final class PqRegistryHashTool { } 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(" hash --chain-id 2800 --registry [,...]"); System.out.println(" generate --chain-id 2800 --arming-height --registry "); diff --git a/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqSchemeSchedule.java b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqSchemeSchedule.java new file mode 100644 index 0000000..a4d1bd4 --- /dev/null +++ b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqSchemeSchedule.java @@ -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 schemeIds) {} + + private final List steps; + + private PqSchemeSchedule(final List 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 steps = new ArrayList<>(); + long lastHeight = -1; + for (final String piesa : Splitter.on(',').split(raw)) { + final List 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 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 schemesAt(final long height) { + Set inForce = Set.of(); + for (final Step s : steps) { + if (s.fromBlock() <= height) { + inForce = s.schemeIds(); + } else { + break; + } + } + return inForce; + } + + /** All steps, ascending. */ + public List 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 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(); + } +} diff --git a/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqSealCache.java b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqSealCache.java index e5e61bd..f13a954 100644 --- a/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqSealCache.java +++ b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqSealCache.java @@ -1,5 +1,5 @@ /* - * Copyright contributors to Besu / Aere Network. + * Copyright contributors to Besu / AERE Network. * * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at @@ -52,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 * Commit messages of the head block, and those are never replayed. * - *

      AND IT IS NOT THE UNBOUND-REGISTRY DEFECT IN ANOTHER COAT. That one was a registry of public - * keys read from a file and BELIEVED. Every seal read back here is re-verified, cryptographically, - * against the anchored registry over M rebuilt from the header this process just loaded - see - * {@link PqSealStore}. A forged file cannot inject a seal without forging a Falcon-512 signature; - * the worst it achieves is the empty cache an absent file already gives. Persistence is OFF unless - * a caller enables it, and the only caller that does is the QBFT controller builder, only when the - * anchor is actually armed. + *

      AND IT IS NOT DEFECT A8 IN ANOTHER COAT. A8 was a registry of public keys read from a file and + * BELIEVED. Every seal read back here is re-verified, cryptographically, against the anchored + * registry over M rebuilt from the header this process just loaded - see {@link PqSealStore}. A + * forged file cannot inject a seal without forging a Falcon-512 signature; the worst it achieves is + * the empty cache an absent file already gives. Persistence is OFF unless a caller enables it, and + * the only caller that does is the QBFT controller builder, only when the anchor is actually armed. * *

      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 diff --git a/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqSealStore.java b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqSealStore.java index fecb299..e6937dd 100644 --- a/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqSealStore.java +++ b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqSealStore.java @@ -1,5 +1,5 @@ /* - * Copyright contributors to Besu / Aere Network. + * Copyright contributors to Besu / AERE Network. * * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at @@ -53,10 +53,10 @@ import org.slf4j.LoggerFactory; * and nobody could propose. Seals come from Commits, Commits come from proposals, proposals need * seals - the same circle, one level down. * - *

      WHY THIS IS NOT THE UNBOUND-REGISTRY DEFECT IN ANOTHER COAT, and the distinction is the - * whole safety argument. That one was a REGISTRY read from a local file and BELIEVED: the - * public keys that decide who is a legitimate signer came out of a file a node could be pointed at - * wrongly, so the file was authority. Nothing here is believed. A Falcon seal is SELF-AUTHENTICATING: {@link + *

      WHY THIS IS NOT DEFECT A8 IN ANOTHER COAT, and the distinction is the whole safety + * argument. A8 was a REGISTRY read from a local file and BELIEVED: the public keys that decide + * who is a legitimate signer came out of a file a node could be pointed at wrongly, so the file was + * authority. Nothing here is believed. A Falcon seal is SELF-AUTHENTICATING: {@link * #readVerified(Path, long, long, Hash, PqSignerRegistry)} re-verifies EVERY seal it reads against * the anchored registry, over the message M rebuilt from the chain-head header this process just * loaded, exactly as the producer does at selection time. A forged, edited or replayed file cannot @@ -320,11 +320,11 @@ public final class PqSealStore { if (seal.getValidatorIndex() < 0 || seal.getSignature() == null || !seen.add(seal.getValidatorIndex()) - // 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. // 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. - // 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 // own head by construction. || 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 * because it was on disk. * - *

      HEIGHT-RESOLVED LOOKUP (2026-08-06): it now carries the HEIGHT the seals belong to. The - * adversarial review of + *

      D2 (2026-08-06): it now carries the HEIGHT the seals belong to. The adversarial review of * 2026-08-02 measured that every registry question in this stack was height-less, so a restart * after a key rotation re-checked seals over an old block against today's keys and dropped them * all as forged. Here the height is not in doubt: the caller has already refused the file unless diff --git a/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqSignerRegistry.java b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqSignerRegistry.java index 3646b9a..bfaa456 100644 --- a/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqSignerRegistry.java +++ b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqSignerRegistry.java @@ -1,5 +1,5 @@ /* - * Copyright contributors to Besu / Aere Network. + * Copyright contributors to Besu / AERE Network. * * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at @@ -37,18 +37,17 @@ import org.apache.tuweni.bytes.Bytes; public interface PqSignerRegistry { /** - * HEIGHT-INDEXED REGISTRY, LOOKUP HARDENING (a). The validator address bound to a registry index - * AT A HEIGHT. + * D-081 / D2 HARDENING (a). The validator address bound to a registry index AT A HEIGHT. * *

      WHY THERE IS NO HEIGHT-LESS FORM HERE, and why this is the change and not a nicety. Until * 2026-08-06 this interface carried BOTH {@code addressForIndex(int)} and a {@code * addressForIndexAt(long,int)} whose body was {@code default { return addressForIndex(idx); }}. - * That default is precisely 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 - * Falcon key is rotated. The default made the defect INVISIBLE TO ITS OWN PROOF - that review's - * probe injected a registry that overrides only the height-less pair, inherits the default, and - * therefore returns exactly the same verdict on repaired and unrepaired code. A probe that cannot - * go red is not a probe. + * Falcon key is rotated. The default made the defect INVISIBLE TO ITS OWN PROOF - the D2 harness + * (adversar-2026-08-02/harness/RuleProbe.java lines 115-131) injects a registry that overrides + * only the height-less pair, inherits the default, and therefore returns exactly the same verdict + * on repaired and unrepaired code. A probe that cannot go red is not a probe. * *

      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 @@ -56,7 +55,7 @@ public interface PqSignerRegistry { * on {@link FalconSealSupport} and under a name that cannot be mistaken for a verification path - * see {@code FalconSealSupport.localSigningAddress()}. * - *

      LOOKUP HARDENING (b-v2), 2026-08-06: this is the HISTORY half of the pair. It is reachable + *

      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 * arming height. The own-head half is {@link #addressForIndexAtOwnHead}, which carries the * measurement that forced the split. @@ -69,18 +68,16 @@ public interface PqSignerRegistry { Address addressForIndexAtHistoric(long blockNumber, int validatorIndex); /** - * LOOKUP HARDENING (b-v2). The address bound to a registry index at a height, asked about THIS - * NODE'S + * D2 HARDENING (b-v2). The address bound to a registry index at a height, asked about THIS NODE'S * OWN HEAD: a block this node is building, or the head it has just restarted onto. * *

      WHY THIS SECOND NAME EXISTS, and it is a measurement and not a taste. The first shape of * hardening (b) refused every unbound height at or above the arming height and decided that from * the block NUMBER alone. On 2026-08-06 that turned six tests red - five in {@code * PqSealPersistenceTest}, the restart path, and one in {@code PqForkValidatorSetChangeTest}, the - * proposer - and that test's message states the operational consequence in one line: the node - * stops producing blocks. In all six the number handed to the guard was 1030 with an arming - * height of 1000, which is exactly what a genuinely historical question at the same instant would - * hand it. + * proposer - and the D078 message states the operational consequence in one line: the node stops + * producing blocks. In all six the number handed to the guard was 1030 with an arming height of + * 1000, which is exactly what a genuinely historical question at the same instant would hand it. * 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. * @@ -99,8 +96,7 @@ public interface PqSignerRegistry { Address addressForIndexAtOwnHead(long blockNumber, int validatorIndex); /** - * HEIGHT-INDEXED REGISTRY, LOOKUP HARDENING (a). Verify a Falcon signature by a registry index AT - * A HEIGHT. Must never + * D-081 / D2 HARDENING (a). Verify a Falcon signature by a registry index AT A HEIGHT. Must never * throw. Abstract for the reason given on {@link #addressForIndexAtHistoric}. * * @param blockNumber the height of the header carrying the seal @@ -112,8 +108,7 @@ public interface PqSignerRegistry { 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 - * or + * D2 HARDENING (b-v2). Verify a Falcon signature over a block THIS NODE holds as its own head or * is building right now. Never refuses for a missing height binding; see {@link * #addressForIndexAtOwnHead} for the measurement that forced the split and for what it still does * refuse. diff --git a/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/SchemeSeal.java b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/SchemeSeal.java new file mode 100644 index 0000000..ed5a638 --- /dev/null +++ b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/SchemeSeal.java @@ -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}"; + } +} diff --git a/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/SealScheme.java b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/SealScheme.java new file mode 100644 index 0000000..228b9a0 --- /dev/null +++ b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/SealScheme.java @@ -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 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 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. + * + *

      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 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 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) {} +} diff --git a/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/SealSchemes.java b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/SealSchemes.java new file mode 100644 index 0000000..1e01fe4 --- /dev/null +++ b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/SealSchemes.java @@ -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 ALL = List.of(FALCON_512, SLH_DSA_128S); + + private SealSchemes() {} + + /** All schemes this build understands, in wire-tag order. */ + public static List 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 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 byWireId(final byte wireId) { + for (final SealScheme s : ALL) { + if (s.wireId() == wireId) { + return Optional.of(s); + } + } + return Optional.empty(); + } +} diff --git a/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/SlhDsaSealScheme.java b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/SlhDsaSealScheme.java new file mode 100644 index 0000000..fddb442 --- /dev/null +++ b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/SlhDsaSealScheme.java @@ -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 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 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 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 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()); + } +} diff --git a/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/blockcreation/PqAnchorProducer.java b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/blockcreation/PqAnchorProducer.java index 8ff0f9d..06cd9ea 100644 --- a/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/blockcreation/PqAnchorProducer.java +++ b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/blockcreation/PqAnchorProducer.java @@ -1,5 +1,5 @@ /* - * Copyright contributors to Besu / Aere Network. + * Copyright contributors to Besu / AERE Network. * * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at @@ -202,14 +202,14 @@ public final class PqAnchorProducer { final int sealCap = cfg.maxSealsCarried().orElse(Integer.MAX_VALUE); int rejectedIneligible = 0; int rejectedInvalid = 0; - // HEIGHT-RESOLVED LOOKUP (2026-08-06): the proposer resolves keys AT THE PARENT'S HEIGHT, the - // same height R2 will use when it re-checks this certificate. Before this the producer asked a - // height-less registry while R2 asked a height-resolved one, so at a rotation height the two - // could disagree about which key set applies - the proposer would assemble a certificate the - // fleet then refuses, and the round would fail for a reason no log named. Building and - // validating now read the same question. + // D2 (2026-08-06): the proposer resolves keys AT THE PARENT'S HEIGHT, the same height R2 will + // use when it re-checks this certificate. Before this the producer asked a height-less registry + // while R2 asked a height-resolved one, so at a rotation height the two could disagree about + // which key set applies - the proposer would assemble a certificate the fleet then refuses, and + // the round would fail for a reason no log named. Building and validating now read the same + // question. 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 // missing schedule is what stopped block production in PqForkValidatorSetChangeTest. final Address signer = @@ -227,9 +227,9 @@ public final class PqAnchorProducer { // THE COST CAP. K is a FLOOR, not a ceiling: without this break the proposer writes every // eligible seal it happened to hear, so a K=3 chain at N=7 carries four, five, six or seven. // Measured 2026-08-07 on a live seven-node run with the threshold at 4: 42 blocks carried 4 - // seals, 36 carried 5, 5 carried 6. At 666 bytes a seal that is roughly 1.7 times the header - // bytes the same chain would write capped at K, and the extra buys NOTHING: what a verifier - // demands is the threshold, not how many seals a proposer volunteers above it. + // seals, 36 carried 5, 5 carried 6. At 666 bytes a seal that is 200.9 GB per node per year + // instead of 120.5, and the extra buys NOTHING: what a verifier demands is the threshold, not + // how many seals a proposer volunteers above it. // // 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 diff --git a/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/tools/PqRegistryHashTool.java b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/tools/PqRegistryHashTool.java index d78385d..a9e7a2a 100644 --- a/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/tools/PqRegistryHashTool.java +++ b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/tools/PqRegistryHashTool.java @@ -20,8 +20,8 @@ import java.nio.file.Paths; 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 - * into genesis as {@code config.pqRegistryHash}. + * AERE A8: compute the canonical hash of a Falcon validator registry so it can be put into genesis + * as {@code config.pqRegistryHash}. * *

      This tool calls THE SAME code the startup guard calls. That is the whole point of it existing * as a class inside {@code consensus:common} rather than as a shell script: a tool that computed the @@ -94,11 +94,11 @@ public final class PqRegistryHashTool { return; } - // AERE CANONICAL FINGERPRINT (2026-08-06). hashFor, not hashV1, and this is the line an - // operator pastes into genesis. For a proof-bound (v2) registry the node's gate compares - // hashV2; printing hashV1 here gives the whole fleet a value NOTHING on a node ever computes. - // Measured on a network of seven on 2026-08-06: all seven start, all seven report the registry - // loaded, and the chain stops at H-1. A wrong instruction is more dangerous than a missing one. + // AERE D-C (2026-08-06). hashFor, not hashV1, and this is the line an operator pastes into + // genesis. For a proof-bound (v2) registry the node's gate compares hashV2; printing hashV1 here + // gives the whole fleet a value NOTHING on a node ever computes. Measured on a network of seven + // on 2026-08-06: all seven start, all seven report the registry loaded, and the chain stops at + // H-1. A wrong instruction is more dangerous than a missing one. // // hashV1 is also useless as an epoch identifier, which is the other reason it cannot merely be // kept alongside: the bind height is not in the v1 pre-image, so two rotation epochs of the same @@ -110,7 +110,7 @@ public final class PqRegistryHashTool { return; } - System.out.println("AERE PQC 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(" source kind : " + reg.kind()); System.out.println(" entries : " + reg.count()); @@ -130,7 +130,7 @@ public final class PqRegistryHashTool { if (reg.proofBound()) { System.out.println(); System.out.println( - " AERE HEIGHT BINDING: schedule this registry at block " + " AERE D-B: schedule this registry at block " + reg.bindHeight() + " AND NOWHERE ELSE. Every row's possession proof and validator claim sign that"); System.out.println( @@ -160,10 +160,10 @@ public final class PqRegistryHashTool { System.out.println(); System.out.println(" Paste into genesis under \"config\":"); System.out.println(); - // AERE HEIGHT BINDING: the recipe this tool prints has to be the recipe the node accepts. - // Since 2026-08-06 a proof-bound registry scheduled at a block other than its bindHeight is - // refused at startup, so printing one here would be manufacturing the configuration the node - // rejects - in a file that goes to all seven nodes at once. + // AERE D-B: the recipe this tool prints has to be the recipe the node accepts. Since 2026-08-06 + // a proof-bound registry scheduled at a block other than its bindHeight is refused at startup, + // so printing one here would be manufacturing the configuration the node rejects - in a file + // that goes to all seven nodes at once. final long at = block == 0L && reg.proofBound() ? reg.bindHeight() : block; if (reg.proofBound() && at != reg.bindHeight()) { System.err.println(); diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D078ThresholdReachabilityTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D078ThresholdReachabilityTest.java new file mode 100644 index 0000000..8b040bc --- /dev/null +++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D078ThresholdReachabilityTest.java @@ -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? + * + *

      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}: + * + *

      + * + * "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". + * + *
      + * + *

      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. + * + *

      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: + * + *

      + *   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
      + * 
      + * + *

      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. + * + *

      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. + * + *

      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); + } +} diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D078ValidatorSetChangeTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D078ValidatorSetChangeTest.java new file mode 100644 index 0000000..836c179 --- /dev/null +++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D078ValidatorSetChangeTest.java @@ -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. + * + *

      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". + * + *

      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. + * + *

      WHAT EACH TEST MEASURES, and why each of them can fail: + * + *

        + *
      1. {@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. + *
      2. {@link #addingOneValidatorMustNotTurnSealAttachmentOff()} - D-078 itself, on the exact + * stimulus in the title: one more validator in the set, with no Falcon key. + *
      3. {@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. + *
      4. {@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. + *
      + * + *

      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

      keyedValidators = new ArrayList<>(); + private final List 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 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
      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 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. + * + *

      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: + * + *

      +   *   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
      +   * 
      + * + *

      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

      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); + } +} diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D079ForkArmingTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D079ForkArmingTest.java new file mode 100644 index 0000000..c1067e6 --- /dev/null +++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D079ForkArmingTest.java @@ -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. + * + *

      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. + * + *

      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. + * + *

      WHAT EACH TEST MEASURES, and how each can fail: + * + *

        + *
      1. {@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. + *
      2. {@link #aMalformedForkBlockRefusesToStart()} and {@link #aNegativeForkBlockRefusesToStart()} + * - the config-time half of the finding, at construction. + *
      3. {@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. + *
      4. {@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. + *
      5. {@link #anAttachHeightBeforeTheObservationHeightRefuses()} and {@link + * #aForkHeightAtTheObservationHeightRefuses()} - half two on its own stimulus: the ordering + * is wrong and the node starts anyway. + *
      6. {@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. + *
      7. {@link #aGenesisAnchoredRegistryNeedsNoObservationHeight()} and {@link + * #anObservationHeightWithoutBlockingIsHarmless()} - the scope controls. A guard that refused + * every blocking configuration would pass every test above and be useless. + *
      + * + *

      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); + } +} diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D081RegistryRotationTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D081RegistryRotationTest.java new file mode 100644 index 0000000..12c091e --- /dev/null +++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D081RegistryRotationTest.java @@ -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. + * + *

      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. + * + *

      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. + * + *

      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. + * + *

      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. + * + *

      {@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? + * + *

      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 enforcedHeights() { + final List 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 availableConfigurations( + final PqRegistryHash.Schedule schedule, + final PqRegistryHash.Registry before, + final PqRegistryHash.Registry after) { + final List 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 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 heights = enforcedHeights(); + final List configurations = + availableConfigurations(schedule, before, after); + + final List report = new ArrayList<>(); + NodeConfiguration complete = null; + for (final NodeConfiguration c : configurations) { + final List 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 report = new ArrayList<>(); + for (final NodeConfiguration c : availableConfigurations(schedule, withCompromised, revoked)) { + boolean all = true; + final List 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 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 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 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 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(); + } +} diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D140FleetRestartArmingTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D140FleetRestartArmingTest.java new file mode 100644 index 0000000..d2b0217 --- /dev/null +++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D140FleetRestartArmingTest.java @@ -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. + * + *

      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)". + * + *

      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. + * + *

      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. + * + *

        + *
      1. {@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. + *
      2. {@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. + *
      3. {@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. + *
      4. {@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. + *
      5. {@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. + *
      + * + *

      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. + * + *

      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. + * + *

      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. + * + *

      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); + } +} diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D141SealPersistenceTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D141SealPersistenceTest.java new file mode 100644 index 0000000..d66a209 --- /dev/null +++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D141SealPersistenceTest.java @@ -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. + * + *

      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. + * + *

      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. + * + *

      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: + * + *

        + *
      1. {@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. + *
      2. {@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. + *
      3. {@link #aCorruptFileDoesNotStopTheNode()} - truncated, random, empty, a directory where the + * file should be. Every one of them yields an empty cache and no exception. + *
      4. {@link #aFileFromAnotherHeightOrAnotherChainIsIgnored()} - the binding checks, before a + * single signature is verified. + *
      5. {@link #theWriteIsAtomicUnderAConcurrentReader()} - a reader hammering the file across 120 + * writes never observes a partial file. + *
      6. {@link #thePathComesFromTheDataDirectory()} - the path is derived, never configured. + *
      7. {@link #whatOneWriteCostsAgainstTheBlockInterval()} - the price of doing this on the + * consensus thread, as a number rather than as a hope. + *
      + * + *

      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

      validators = new ArrayList<>(); + private final List 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. + * + *

      Three shapes of forgery are in the one file, because "a forged seal" is not one thing: + * + *

        + *
      1. 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. + *
      2. index 4, random bytes of exactly the right length. The cheapest forgery there is. + *
      3. 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. + *
      + */ + @Test + public void aForgedSealInTheFileIsRejectedAtReadAndNeverEntersTheCache() throws Exception { + final List 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 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 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 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 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 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 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 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 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
      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); + } +} diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D146ArmingGateTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D146ArmingGateTest.java new file mode 100644 index 0000000..079183b --- /dev/null +++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D146ArmingGateTest.java @@ -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. + * + *

      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. + * + *

      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. + * + *

      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. + * + *

      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 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. + * + *

      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. + * + *

      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. + * + *

      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. + * + *

      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 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. + * + *

      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); + } +} diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D147InertBinaryTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D147InertBinaryTest.java new file mode 100644 index 0000000..68737f3 --- /dev/null +++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D147InertBinaryTest.java @@ -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. + * + *

      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. + * + *

      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. + * + *

      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. + * + *

      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. + * + *

      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. + * + *

      {@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 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 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. + * + *

      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. + * + *

      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 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. + * + *

      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. + * + *

      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

      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 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}. + * + *

      {@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 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 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 bound; + + private void bind(final List 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 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 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); + } + } +} diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D177NeutralNamesTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D177NeutralNamesTest.java new file mode 100644 index 0000000..05136c6 --- /dev/null +++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D177NeutralNamesTest.java @@ -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. + * + *

      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"); + } + } +} diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D2CallerIntentTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D2CallerIntentTest.java new file mode 100644 index 0000000..cc1ce06 --- /dev/null +++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D2CallerIntentTest.java @@ -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. + * + *

      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. + * + *

      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. + * + *

      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. + * + *

      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 privateKeys = new ArrayList<>(); + private final List

      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 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 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 entries) + throws Exception { + final List 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); + } +} diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D2RegistryHeightRefusalTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D2RegistryHeightRefusalTest.java new file mode 100644 index 0000000..1ebb6b8 --- /dev/null +++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D2RegistryHeightRefusalTest.java @@ -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. + * + *

      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. + * + *

      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. + * + *

      WHAT THIS FILE ASSERTS, as a property and not as a scenario: at and above the arming height, + * a node that cannot say which key set was in force must REFUSE, not guess. 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. + * + *

      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 privateKeys = new ArrayList<>(); + private final List

      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 entries) + throws Exception { + final List 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); + } +} diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/FalconAttachIntervalTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/FalconAttachIntervalTest.java index 2d593d4..0142443 100644 --- a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/FalconAttachIntervalTest.java +++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/FalconAttachIntervalTest.java @@ -3,10 +3,8 @@ * * WHY THIS EXISTS, and the cost that demanded it. On 8 August Falcon seal attachment was switched * on across all seven validators of chain 2800. The header went from 525 to 3844 bytes, that is - * FIVE seals on EVERY block: more than seven times the previous header, every ~523 ms. On a chain - * with no transactions the headers are close to everything that gets written to disk, so that - * multiplier is exactly the multiplier of database growth, and it exceeds any reasonable - * provisioning. + * FIVE seals on EVERY block. At ~60.3 million blocks per year that is ~200 GB per year per node, + * measured, and the tightest host had 12 GB free. That is 23 days. * * 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 @@ -27,7 +25,7 @@ * and it is a dial, not an accident. Algorand ships the same shape at 1 in 256 or rarer. * * Same property as in `PqAnchorIntervalTest`, a different code path. Two paths need two proofs: - * precisely because we had a proof on one of them only, we paid 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; @@ -95,23 +93,23 @@ class FalconAttachIntervalTest { // --------------------------------------------------------------------------------------------- @Test void anIntervalOfOneHundredCarriesExactlyOneHeightInOneHundred() { - int cu = 0; + int with = 0; for (long n = ATTACH; n < ATTACH + 10_000; n++) { if (FalconSealSupport.isAttachHeight(n, ATTACH, OptionalInt.of(100))) { - cu++; + with++; 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 - int fara = 0; + int without = 0; for (long n = ATTACH; n < ATTACH + 10_000; n++) { 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 void theMeasuredCostOfEachSettingIsWhatWeToldTheFounder() { 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(3, 1, octetiPeSigiliu, blocuriPeAn)).isBetween(105L, 125L); // cap only - assertThat(gbPeAn(3, 32, octetiPeSigiliu, blocuriPeAn)).isBetween(3L, 5L); // cap + 32 - assertThat(gbPeAn(3, 100, octetiPeSigiliu, blocuriPeAn)).isBetween(1L, 2L); // cap + 100 + assertThat(gbPeAn(5, 1, bytesPerSeal, blocuriPeAn)).isBetween(180L, 210L); // today + assertThat(gbPeAn(3, 1, bytesPerSeal, blocuriPeAn)).isBetween(105L, 125L); // cap only + assertThat(gbPeAn(3, 32, bytesPerSeal, blocuriPeAn)).isBetween(3L, 5L); // cap + 32 + assertThat(gbPeAn(3, 100, bytesPerSeal, blocuriPeAn)).isBetween(1L, 2L); // cap + 100 - // and the boundary that matters for any provisioning decision: starting from a fixed space - // budget, how many days each setting lasts. The budget below is a parameter of the proof, kept - // deliberately small so that the order of magnitude between the settings is visible. - assertThat(zile(12L, gbPeAn(5, 1, octetiPeSigiliu, blocuriPeAn))).isLessThan(30L); - assertThat(zile(12L, gbPeAn(3, 32, octetiPeSigiliu, blocuriPeAn))).isGreaterThan(700L); + // and the boundary that matters for the disk decision: at 12 GB free, how many days are left + assertThat(zile(12L, gbPeAn(5, 1, bytesPerSeal, blocuriPeAn))).isLessThan(30L); + assertThat(zile(12L, gbPeAn(3, 32, bytesPerSeal, blocuriPeAn))).isGreaterThan(700L); } private static long gbPeAn( - final int sigilii, final int interval, final long octetiPeSigiliu, final long blocuriPeAn) { - return (long) sigilii * octetiPeSigiliu * blocuriPeAn / interval / (1024L * 1024L * 1024L); + final int seals, final int interval, final long bytesPerSeal, final long blocuriPeAn) { + return (long) seals * bytesPerSeal * blocuriPeAn / interval / (1024L * 1024L * 1024L); } private static long zile(final long gbLiberi, final long gbPeAn) { diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/HybridSealProducerTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/HybridSealProducerTest.java new file mode 100644 index 0000000..5569abe --- /dev/null +++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/HybridSealProducerTest.java @@ -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 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 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(); + } +} diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/HybridSealSupportTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/HybridSealSupportTest.java new file mode 100644 index 0000000..971389b --- /dev/null +++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/HybridSealSupportTest.java @@ -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 props = new HashMap<>(); + final Map 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 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(); + } +} diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/HybridSignerRegistryTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/HybridSignerRegistryTest.java new file mode 100644 index 0000000..87bca04 --- /dev/null +++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/HybridSignerRegistryTest.java @@ -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. + 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 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); + } +} diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorConfigTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorConfigTest.java index 0536d2e..8146b83 100644 --- a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorConfigTest.java +++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorConfigTest.java @@ -1,5 +1,5 @@ /* - * Copyright contributors to Besu / Aere Network. + * Copyright contributors to Besu / AERE Network. * * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at @@ -155,9 +155,8 @@ public class PqAnchorConfigTest { @Test public void emergencyCeilingLowersTheThresholdAndCanNeverRaiseIt() { - // THE EMERGENCY CEILING, the only de-arm that works when the chain is ALREADY STOPPED: a halted - // chain cannot deliver a height-scheduled configuration change, so the control has to be local - // to the node. + // D1, the only de-arm that works when the chain is ALREADY STOPPED: a halted chain cannot + // deliver a height-scheduled configuration change, so the control has to be local to the node. final PqAnchorConfig lowered = new PqAnchorConfig(2800L, 1000L, schedule(), OptionalInt.of(1), false); 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 * operator at three in the morning gets one screen, and it has to name the field, the value read, * the problem, the repair, and the sequencing rule that keeps a fleet restart from killing the - * chain at quorum 5 of 7. + * chain. + * + *

      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 public void theRefusalMessageCarriesEverythingAnOperatorNeedsAtThreeInTheMorning() { @@ -676,7 +681,8 @@ public class PqAnchorConfigTest { .hasMessageContaining("a step exactly at " + H) .hasMessageContaining("FIX correct BESU_OPTS on THIS node") .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"); } diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorEmergencyConfigTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorEmergencyConfigTest.java index 222f45b..67fb25b 100644 --- a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorEmergencyConfigTest.java +++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorEmergencyConfigTest.java @@ -1,5 +1,5 @@ /* - * Copyright contributors to Besu / Aere Network. + * Copyright contributors to Besu / AERE Network. * * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorIntervalTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorIntervalTest.java index 08afdba..d1cd9fd 100644 --- a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorIntervalTest.java +++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorIntervalTest.java @@ -2,10 +2,8 @@ * AERE, 2026-08-07. The anchor interval: a certificate on every Nth block instead of every block. * * WHY THIS EXISTS. At ~523 ms per block we produce 165,248 blocks per day, 23 times more than - * Ethereum. A certificate in EVERY block multiplies the header by almost five even with the cap at - * K=3, and on a chain with empty blocks the headers are close to everything that gets written to - * disk. So that multiplier is the multiplier of database growth, and the design does not fit on a - * reasonably provisioned node. The interval divides it by N. + * Ethereum. A certificate in EVERY block costs 120.5 GB per year per node even with the cap at K=3. + * The fleet's disks are 38 and 75 GB, so the design does not fit anywhere. * * 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 @@ -132,10 +130,10 @@ class PqAnchorIntervalTest { // --------------------------------------------------------------------------------------------- @Test 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}) { - assertThat(niciodata.isAnchorHeight(n)).as("height %d", n).isFalse(); - assertThat(niciodata.anchorAppliesAt(n)).as("height %d", n).isFalse(); + assertThat(never.isAnchorHeight(n)).as("height %d", n).isFalse(); + assertThat(never.anchorAppliesAt(n)).as("height %d", n).isFalse(); } } diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorMinSealsFloorTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorMinSealsFloorTest.java index 53917ee..651c63b 100644 --- a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorMinSealsFloorTest.java +++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorMinSealsFloorTest.java @@ -1,16 +1,15 @@ /* - * AERE, 2026-08-07. THE THRESHOLD FLOOR: a schedule whose effective K is zero everywhere leaves the - * anchor armed and completely toothless, forever, and every tool reports GREEN the whole time it is - * happening, because they all measure what was ASKED FOR and the request is valid. + * AERE D-147, 2026-08-07. THE THRESHOLD FLOOR: a schedule whose effective K is zero everywhere + * leaves the anchor armed and completely toothless, forever, and every tool reports GREEN the whole + * time it is happening, because they all measure what was ASKED FOR and the request is valid. * * The loader already guarded this consequence in its own words, "K would be 0 at every height * and an ARMED node would accept empty certificates", but only for a MISSING schedule. A schedule * that is PRESENT and of the form ":0" reaches the same state, and it used to pass. * - * And it is not theoretical: the recommended activation schedule has the form ":0,:3", - * that is, it STARTS at zero, precisely in order to leave a warm-up window. If the second half is - * lost to a stray quote or a truncated variable, what remains is exactly the dangerous form, and - * that is why the floor looks at the WHOLE schedule. + * And it is not theoretical: PLAN-ACTIVARE recommends ":0,:3", which STARTS at zero. + * If the second half is lost to a stray quote or a truncated variable, what remains is exactly the + * dangerous form. */ package org.hyperledger.besu.consensus.common.bft; diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorProducerCacheHygieneTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorProducerCacheHygieneTest.java new file mode 100644 index 0000000..10cff59 --- /dev/null +++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorProducerCacheHygieneTest.java @@ -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. + * + *

      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. + * + *

      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(); + } +} diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorProducerCostTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorProducerCostTest.java index 252fd34..d337674 100644 --- a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorProducerCostTest.java +++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorProducerCostTest.java @@ -4,9 +4,8 @@ * WHY THIS EXISTS. On 7 August `aere.pq.anchor.maxSeals` and `aere.pq.anchorInterval` were built, * and their configuration guards were proven the same day. But the cut in the producer, the code * that ACTUALLY stops seals being written past the cap, and that ACTUALLY skips the heights with no - * anchor, stayed an ASSERTION: there was no producer harness in the tree, and we had just seen, at - * the wiring of the rules into the validation chain, what a piece of code that no proof touches - * costs. + * anchor, stayed an ASSERTION: there was no producer harness in the tree, and D-148 had just shown + * what a piece of code that no proof touches costs. * * This class touches it. It counts the seals written, it does not assume them. * diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorSealCapTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorSealCapTest.java index 5c61464..c9bfd2e 100644 --- a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorSealCapTest.java +++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorSealCapTest.java @@ -3,9 +3,9 @@ * * WHY IT EXISTS. K is a FLOOR, not a cap. Measured on a live ten-node run with the threshold at 4: * 42 blocks carried 4 seals, 36 carried 5, 5 carried 6. The proposer writes every seal it heard and - * that is eligible, not as many as the threshold demands. At 666 bytes a seal, that means about two - * thirds more header written than the threshold asks for, and the surplus buys nothing: what a - * verifier demands is THE THRESHOLD. + * that is eligible, not as many as the threshold demands. At 666 bytes a seal, that means 200.9 GB + * per node per year instead of 120.5, and the surplus buys nothing: what a verifier demands is THE + * THRESHOLD. * * WHAT THIS FILE GUARDS, and this is the dangerous part: a cap set BELOW the highest K in the * schedule makes the proposer write certificates its own fleet rejects, at every height from the diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorTest.java index 0e65d7f..5bea4d3 100644 --- a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorTest.java +++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorTest.java @@ -1,5 +1,5 @@ /* - * Copyright contributors to Besu / Aere Network. + * Copyright contributors to Besu / AERE Network. * * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorThresholdGuardTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorThresholdGuardTest.java index 9c7be44..ebf7975 100644 --- a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorThresholdGuardTest.java +++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorThresholdGuardTest.java @@ -1,5 +1,5 @@ /* - * Copyright contributors to Besu / Aere Network. + * Copyright contributors to Besu / AERE Network. * * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at @@ -27,9 +27,10 @@ import org.junit.jupiter.api.Test; * AERE GARDA-PRAG: the PLANTED FAILURE for the seal-threshold guard. * *

      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 - * at N=4, and {@link #growingTheValidatorSetDoesNotBuyQuorumMargin()} is the test that would go green - * under the WRONG bound {@code K > N - f} and red under the right one. + * that the guard sees it. REVISED 2026-08-20: the bound asserted is {@code K <= N - f} (availability + * under the fault budget), which is 5 at N=7 and 3 at N=4. Until D-227 the bound was + * {@code quorum - 1}, and {@link #atNineValidatorsTheQuorumIsReachableAndAboveNMinusFIsNot()} + * carries the dated history of that reversal, with the measurement that forced it. * *

      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 @@ -49,27 +50,30 @@ class PqAnchorThresholdGuardTest { @Test 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(PqAnchorThresholdGuard.maxConfigurableThreshold(N_LIVE)).isEqualTo(4); + assertThat(PqAnchorThresholdGuard.maxConfigurableThreshold(N_LIVE)).isEqualTo(5); assertThat(PqAnchorThresholdGuard.byzantineBudget(N_LIVE)).isEqualTo(2); assertThat(BftHelpers.calculateRequiredValidatorQuorum(4)).isEqualTo(3); - assertThat(PqAnchorThresholdGuard.maxConfigurableThreshold(4)).isEqualTo(2); + assertThat(PqAnchorThresholdGuard.maxConfigurableThreshold(4)).isEqualTo(3); } @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( () -> 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)) .isInstanceOf(FalconSealSupport.ActivationConfigException.class) .hasMessageContaining(PqAnchorThresholdGuard.CODE) .hasMessageContaining("REFUSING TO START") - .hasMessageContaining("reaches 5 at height " + (H + 21_600L)) - .hasMessageContaining("may be configured at this set size is 4"); + .hasMessageContaining("reaches 6 at height " + (H + 21_600L)) + .hasMessageContaining("may be configured at this set size is 5"); } @Test @@ -84,14 +88,14 @@ class PqAnchorThresholdGuardTest { @Test 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. assertThatThrownBy( () -> PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort( - armed(Map.of(H, 5), OptionalInt.empty()), N_LIVE)) + armed(Map.of(H, 6), OptionalInt.empty()), N_LIVE)) .isInstanceOf(FalconSealSupport.ActivationConfigException.class) - .hasMessageContaining("reaches 5 at height " + H); + .hasMessageContaining("reaches 6 at height " + H); } @Test @@ -99,7 +103,7 @@ class PqAnchorThresholdGuardTest { assertThatCode( () -> 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)) .doesNotThrowAnyException(); } @@ -115,23 +119,33 @@ class PqAnchorThresholdGuardTest { } @Test - void growingTheValidatorSetDoesNotBuyQuorumMargin() { - // THIS is the test that separates the right bound from the wrong one. At N=9 the quorum is 6 - // while N-f is 7, so the rule "refuse when K > N - f" would ACCEPT K=7, which is a rung no - // proposer can ever reach. Both 6 and 7 must be refused. + void atNineValidatorsTheQuorumIsReachableAndAboveNMinusFIsNot() { + // HISTORY, kept on purpose: until 2026-08-20 this test was named + // growingTheValidatorSetDoesNotBuyQuorumMargin and asserted that K=6 and K=7 are both refused + // at N=9, because pre-D-227 a proposer could gather at most quorum seals. D-227's late-seal + // salvage changed the physics (mainnet measurement: 8-9 seals per certificate across 5,400 + // anchors), so growing the set NOW buys reachable rungs. The fatal bound is availability under + // the fault budget: N - f = 7 at N=9. 6 and 7 start (loudly); 8 is refused. assertThat(BftHelpers.calculateRequiredValidatorQuorum(9)).isEqualTo(6); assertThat(9 - PqAnchorThresholdGuard.byzantineBudget(9)).isEqualTo(7); + assertThat(PqAnchorThresholdGuard.maxConfigurableThreshold(9)).isEqualTo(7); - assertThatThrownBy( + assertThatCode( () -> PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort( 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( () -> 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); assertThatCode( @@ -142,18 +156,20 @@ class PqAnchorThresholdGuardTest { } @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( () -> 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) .hasMessageContaining("quorum for the 4 validators"); assertThatCode( () -> 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(); } @@ -163,17 +179,18 @@ class PqAnchorThresholdGuardTest { assertThatCode( () -> 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(); } @Test 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( () -> 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); } diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorV2Test.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorV2Test.java new file mode 100644 index 0000000..63f4f4f --- /dev/null +++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorV2Test.java @@ -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 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 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 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 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 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 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 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 cert = + List.of( + new SchemeSeal(FALCON, 0, Bytes.wrap(sigFalcon)), + new SchemeSeal(SLHDSA, 0, Bytes.wrap(sigSlh))); + final List 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(); + } + } +} diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqArmingGateTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqArmingGateTest.java index 41222af..73c50ca 100644 --- a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqArmingGateTest.java +++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqArmingGateTest.java @@ -1,5 +1,5 @@ /* - * Copyright contributors to Besu / Aere Network. + * Copyright contributors to Besu / AERE Network. * * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at @@ -33,10 +33,10 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; /** - * REGISTRY BINDING AT ARM TIME, THE LINE THAT WAS MISSING. {@code - * PqRegistryHash.requireBindingsOrThrow} was delivered with its own tests, and nothing called it. - * Its own javadoc said so: "NOT WIRED YET ... the call belongs beside AERE-PQC-REG-ARM-01 in - * FalconSealSupport". This class measures the wire. + * 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. * *

      WHAT THE WIRE BUYS, stated as the thing that is actually true. Before it, an ARMED node loaded * a v1 registry without a word, and the registry decides who a Falcon seal is credited to. Measured @@ -207,7 +207,7 @@ public class PqArmingGateTest { + "trigger is ARMING and not the file") .doesNotThrowAnyException(); assertThat(FalconSealSupport.instance().registrySize()) - .describedAs("and an unarmed node's registry is loaded exactly as it was before this guard") + .describedAs("and an unarmed node's registry is loaded exactly as it was before D-146") .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 * test is what stops that from being a silent decision. * - *

      This guard is about mis-ATTRIBUTION, which needs rows; an empty registry credits nobody. The - * condition + *

      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 @@ -243,7 +242,7 @@ public class PqArmingGateTest { System.setProperty("aere.falcon.testnetAllowSmallFleet", "true"); assertThatCode(FalconSealSupport::instance) - .describedAs("K=0 over an empty registry is the warm-up regime, not a mis-attribution defect") + .describedAs("K=0 over an empty registry is the warm-up regime, not a D-146 defect") .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 - * 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. * *

      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 @@ -291,8 +290,8 @@ public class PqArmingGateTest { /** * Arm through the CERTIFICATE ANCHOR only, leaving {@code aere.falcon.forkBlock} unset. The - * threshold is 2, which {@code worstCaseKeyedSigners(4, 4)} = 3 guarantees, so the - * threshold-reachability guard next door stays silent and cannot be mistaken for this one. + * 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)); diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqCallerIntentTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqCallerIntentTest.java index 0af0116..3800bfe 100644 --- a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqCallerIntentTest.java +++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqCallerIntentTest.java @@ -1,5 +1,5 @@ /* - * Copyright contributors to Besu / Aere Network. + * Copyright contributors to Besu / AERE Network. * * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at @@ -39,7 +39,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; /** - * 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. * *

      WHAT THE FIRST SHAPE DID, MEASURED AND NOT ARGUED. On 2026-08-06 hardening (b) refused every * unbound height at or above the arming height, deciding from the block NUMBER alone. Run against @@ -50,7 +50,7 @@ import org.junit.jupiter.api.io.TempDir; * height of 1000. A genuinely historical question, in the same process in the same second, hands * the guard exactly those numbers too. No arithmetic on the height separates them. * - *

      THE OPERATIONAL CONSEQUENCE, in the words of that failure itself: {@code refusing to + *

      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. @@ -64,7 +64,7 @@ import org.junit.jupiter.api.io.TempDir; *

      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 each was measured before this class was allowed to count as evidence. + * directions and both are recorded in the evidence directory. */ public class PqCallerIntentTest { @@ -100,10 +100,10 @@ public class PqCallerIntentTest { @BeforeEach public void setUp() throws Exception { - // AERE REGISTRY BINDING (2026-08-06): a v2, PROOF-BOUND registry. It used to be v1, with - // addresses spelled 0xA00+i, which no secp256k1 key can sign for, so this fixture described a - // fleet that could never satisfy AERE-PQC-REG-ARM-02 once that guard was wired. The registry is - // bound at H, the height this fixture arms the anchor from. + // 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 @@ -177,7 +177,7 @@ public class PqCallerIntentTest { .describedAs( "HISTORY door at 1030, armed from 1000, no schedule: a node judging somebody else's " + "header cannot say which keys were in force there, so it REFUSES. Answering from " - + "the head registry here is the rotation defect verbatim") + + "the head registry here is D2/T2 verbatim") .isFalse(); assertThat(pqc.verifyAtOwnHead(OWN_HEAD, 0, MESSAGE, sealByIndexZero)) @@ -206,7 +206,7 @@ public class PqCallerIntentTest { .describedAs( "PqSealPersistenceTest restored 0 of 3 genuine seals under the first shape. A node " + "that cannot re-read its own seal file after a restart is a node that cannot " - + "propose, and the file is the documented way out of that restart deadlock") + + "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") @@ -282,13 +282,13 @@ public class PqCallerIntentTest { final FalconSealSupport pqc = FalconSealSupport.instance(); final PqRegistryHash.Registry held = PqRegistryHash.loadAuto(genesisPath); final Map entries = new LinkedHashMap<>(); - // AERE REGISTRY BINDING (2026-08-06): hashFor, not hashV1. A schedule entry has to carry the - // canonical hash OF THE REGISTRY IT NAMES, and this fixture's registry is now v2, which hashes - // under a different domain tag. MEASURED: leaving hashV1 here made the entry name a registry - // nobody holds, and the height-resolved lookups fell through to a refusal - a green test - // turning red for a reason that had nothing to do with what it measures. This is the same - // breakage a real genesis takes: any config.pqRegistryHash computed before the registry was - // rebuilt as v2 stops matching the moment it is rebuilt. + // 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)); diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqFleetRestartArmingTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqFleetRestartArmingTest.java index b784d90..aa60447 100644 --- a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqFleetRestartArmingTest.java +++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqFleetRestartArmingTest.java @@ -1,5 +1,5 @@ /* - * Copyright contributors to Besu / Aere Network. + * Copyright contributors to Besu / AERE Network. * * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at @@ -38,11 +38,11 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; /** - * THE FLEET-RESTART DEADLOCK, AND THE STATE MACHINE THE REPAIR MOVES. + * D-140. THE FLEET-RESTART DEADLOCK, AND THE STATE MACHINE THE REPAIR MOVES. * - *

      MEASURED FIRST, ON A NETWORK, NOT ASSUMED. A full activation rehearsal on a seven-node test - * network found that with the anchor armed at K>0 a SIMULTANEOUS restart of every validator stops - * the chain for good. The node said it verbatim: "refusing to + *

      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)". * @@ -75,8 +75,8 @@ import org.junit.jupiter.api.io.TempDir; * no-op rather than a second registry load. *

    • {@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 the seal-attachment repair, and the line it logged - * came from a condition this tree no longer contains. + * javadoc: the rehearsal binary predates D-078, and the line it logged came from a condition + * this tree no longer contains. * * *

      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 * simultaneous restart with this binary. This class measures the decision the deadlock hinges on. */ +// The D-140 label is our internal finding id. It names a fact about this +// code, not anything outside it. public class PqFleetRestartArmingTest { - /** Fleet size: 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; /** Height at which the anchor contract is expected to be observable. */ @@ -110,6 +116,21 @@ public class PqFleetRestartArmingTest { @BeforeEach public void setUp() throws Exception { + // DATED 2026-08-20. This class never arms the certificate anchor, but FalconSealSupport's + // constructor consults it (anchorArmedFrom() -> PqAnchorProducer.config(), a per-JVM cache): + // a neighbouring test class that leaves an ARMED anchor config cached in this JVM turns every + // proof-less fixture below into an AERE-PQC-REG-ARM-02 refusal. Measured on the production + // tree that day: this class ALONE 5/5 green, inside the full suite the same 5 red, identical + // sources -- the 2026-08-11 order-luck lesson verbatim ("clearing the properties does not + // clear the caches"). The defence belongs to the consumer: start from an unarmed anchor, + // cache and properties both. + for (final String p : System.getProperties().stringPropertyNames()) { + if (p.startsWith("aere.pq.")) { + System.clearProperty(p); + } + } + org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer.useConfigForTesting( + null); resetFalconSingleton(); final SecureRandom rnd = SecureRandomProvider.createSecureRandom(); @@ -344,9 +365,9 @@ public class PqFleetRestartArmingTest { * The seven-node rehearsal ran a GENESIS-anchored registry, and the line it logged after the * simultaneous restart was the COVERAGE one: "no validator set has been observed yet, so registry * COVERAGE cannot be proven. Attachment stays OFF (fail-safe)". That condition does not exist in - * this tree: {@code grep} for it returns nothing, because the seal-attachment repair took the - * fleet-wide question out of the per-commit gate. The rehearsal binary was built before that - * repair landed, and still had it. + * 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. * *

      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 diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqForkArmingTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqForkArmingTest.java index 2f76543..f7276f9 100644 --- a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqForkArmingTest.java +++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqForkArmingTest.java @@ -1,5 +1,5 @@ /* - * Copyright contributors to Besu / Aere Network. + * Copyright contributors to Besu / AERE Network. * * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at @@ -30,11 +30,11 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; /** - * FORK-HEIGHT ARMING. THE MEASUREMENT THAT DID NOT EXIST. + * D-079. THE MEASUREMENT THAT DID NOT EXIST. * - *

      The concern, as it was written down: a malformed forkBlock falls OPEN, with only a log line, - * and arming it at or before the anchor observation height passes undetected. It stood as an - * assertion with no command behind it for months. This file is the command that can fail. + *

      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. * *

      Both halves of the finding are about the SAME shape of defect, the one the Holesky Pectra * incident of February 2025 made expensive for everybody: a fork-activation parameter that is wrong @@ -75,6 +75,8 @@ import org.junit.jupiter.api.io.TempDir; * the guards that already abort, and nothing in this tree catches {@code * FalconSealSupport.ActivationConfigException}. */ +// The D-079 label is our internal finding id. It names a fact about this +// code, not anything outside it. public class PqForkArmingTest { /** Fleet size; nine, because the blocking guard refuses to arm below nine. */ @@ -92,8 +94,8 @@ public class PqForkArmingTest { private static final String ANCHOR_ADDRESS = "0x0000000000000000000000000000000000000fa1"; /** - * REGISTRY BINDING: the chain this fixture's registries are BOUND to. Every proof commits to it, - * so it has to be stated rather than defaulted. + * 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; @@ -104,7 +106,7 @@ public class PqForkArmingTest { @BeforeEach 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 // secp256k1 key can sign for, so this whole fixture became unstartable the moment // AERE-PQC-REG-ARM-02 was wired into the constructor. diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqForkThresholdReachabilityTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqForkThresholdReachabilityTest.java index f9e4f36..81bdb8b 100644 --- a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqForkThresholdReachabilityTest.java +++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqForkThresholdReachabilityTest.java @@ -1,5 +1,5 @@ /* - * Copyright contributors to Besu / Aere Network. + * Copyright contributors to Besu / AERE Network. * * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at @@ -33,8 +33,7 @@ import org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer; import org.junit.jupiter.api.io.TempDir; /** - * THRESHOLD REACHABILITY, THE HALF THAT WAS STILL OPEN: is the threshold K one the fleet can be - * GUARANTEED to meet? + * D-078, THE HALF THAT WAS STILL OPEN: is the threshold K one the fleet can be GUARANTEED to meet? * *

      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 @@ -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 * 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 address-binding guard already refused - * to allow for a non-address-bound manifest. + * configuration error can take, and it is the same shape the A8 repair already refused to allow for + * a non-address-bound manifest. * *

      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 @@ -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 * 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 the fork-arming - * configuration guard. + * AERE-PQC-CFG-UNSAFE-04 and, for the fork height, AERE-PQC-CFG-UNSAFE-06/07 in D-079. * *

      NOT MEASURED here, and named so it is not read as covered: what a LIVE fleet does in the rounds * between the vote landing and the first proposer failing. That needs a network. This class measures * the decision, which is the thing a node can be stopped from taking. */ +// The D-078 label is our internal finding id. It names a fact about this +// code, not anything outside it. public class PqForkThresholdReachabilityTest { /** Anchor activation height H. */ @@ -96,8 +96,8 @@ public class PqForkThresholdReachabilityTest { private static final int K = 5; /** - * REGISTRY BINDING: the chain the registries this fixture writes are BOUND to. It is the same - * value {@link #armAnchor} states in {@code aere.pq.chainId}: a registry bound to one chain and an + * 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; @@ -198,8 +198,8 @@ public class PqForkThresholdReachabilityTest { @Test public void withNoAnchorConfiguredTheGuardIsInert() throws Exception { - // With aere.pq.anchorBlock unset there is no anchor, so K does not exist and there is nothing - // to compare. A guard that could stop a node in that state would be a new way to lose the fleet, + // 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"); @@ -227,9 +227,8 @@ public class PqForkThresholdReachabilityTest { @Test public void aPositiveThresholdWithNoAnchoredKeysMustRefuseToStart() throws Exception { // No manifest anywhere and K=5: guaranteed is 0, so every block at or above H would be rejected - // for want of a certificate nobody can produce. Distinct from the genesis-binding refusal, - // which only fires when aere.falcon.forkBlock is set; the anchor path has its own arming - // height. + // for want of a certificate nobody can produce. Distinct from the 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); @@ -302,7 +301,7 @@ public class PqForkThresholdReachabilityTest { * genesis is rather than by a flag. */ 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 // is on the row, and the 0xA00+i addresses this used to spell have no key behind them. final KeccakDigest kd = new KeccakDigest(256); diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqForkValidatorSetChangeTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqForkValidatorSetChangeTest.java index 113c557..724650d 100644 --- a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqForkValidatorSetChangeTest.java +++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqForkValidatorSetChangeTest.java @@ -1,5 +1,5 @@ /* - * Copyright contributors to Besu / Aere Network. + * Copyright contributors to Besu / AERE Network. * * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at @@ -52,12 +52,12 @@ import org.junit.jupiter.api.io.TempDir; import org.mockito.quality.Strictness; /** - * VALIDATOR-SET CHANGE UNDER AN ARMED ANCHOR. THE MEASUREMENT THAT DID NOT EXIST. + * D-078. THE MEASUREMENT THAT DID NOT EXIST. * - *

      The concern, as it was written down, was this: if PQC were armed, an ordinary add-validator - * vote would stop the chain, because the Falcon blocking quorum follows the dynamic set and cannot - * be reached inside the vote window. It was carried as UNMEASURED, on the argument that the direct - * measurement would require ARMING PQC on a chain, which is exactly the thing that stops the chain. + *

      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". * *

      That is true of a whole chain. It is NOT true of the decision that stops it. Every step from * "the validator set changed" to "no block can be proposed" is taken by three objects in this @@ -65,16 +65,16 @@ import org.mockito.quality.Strictness; * decides whether this node emits a Falcon seal at all, {@link PqSealCache} holds what was heard, * and {@link PqAnchorProducer#apply} decides whether this node may propose. This class drives those * three with a REAL address-bound genesis-anchored registry and REAL Falcon-512 keys, and asks the - * question that was held to be unaskable. + * question the registry says cannot be asked. * *

      WHAT EACH TEST MEASURES, and why each of them can fail: * *

        *
      1. {@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 the question - * under test. This one fails if the fixture is not genuinely armed. - *
      2. {@link #addingOneValidatorMustNotTurnSealAttachmentOff()} - the concern itself, on the exact + * 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. + *
      3. {@link #addingOneValidatorMustNotTurnSealAttachmentOff()} - D-078 itself, on the exact * stimulus in the title: one more validator in the set, with no Falcon key. *
      4. {@link #aNodeStartedAboveTheAnchorHeightMustStillAttach()} - the SAME halt through a much * more ordinary door than a vote: a restart. Above the anchor height the only caller of @@ -90,6 +90,8 @@ import org.mockito.quality.Strictness; * live fleet takes to stop once every proposer refuses, and what a syncing node does meanwhile. * Those need a network, and the network run is separate evidence. */ +// The D-078 label is our internal finding id. It names a fact about this +// code, not anything outside it. public class PqForkValidatorSetChangeTest { /** Anchor activation height H used throughout. */ @@ -121,7 +123,7 @@ public class PqForkValidatorSetChangeTest { // equals the hash stored in the genesis alloc. The digest is accumulated here in lockstep with // the manifest text, so the fixture is anchored the same way a real genesis is. // - // 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 // address is on the row and no key produces the 0xA00+i addresses this used to spell. 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 @@ -231,9 +233,10 @@ public class PqForkValidatorSetChangeTest { assertThat(pqc.attachmentArmed(H + 3L)) .describedAs( - "one ordinary add-validator vote must not switch Falcon seal ATTACHMENT off. It is a " - + "fleet-wide fact, so it turns off on EVERY node at the same height, and with no " - + "node attaching no proposer can gather the K=%d seals an anchored header needs.", + "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))) @@ -350,7 +353,7 @@ public class PqForkValidatorSetChangeTest { * Every other test here asserts that the gate says YES. Replace {@code attachmentArmed} with * {@code return true} and all of them still pass, which would make this file a proof that cannot * fail. These four assertions are what makes that substitution impossible: each names a condition - * the attachment repair deliberately did NOT touch. + * the D-078 repair deliberately did NOT touch. * * @throws Exception if the fixture cannot be rebuilt */ diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqInertBinaryTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqInertBinaryTest.java index f8a26fe..80a2cef 100644 --- a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqInertBinaryTest.java +++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqInertBinaryTest.java @@ -1,5 +1,5 @@ /* - * Copyright contributors to Besu / Aere Network. + * Copyright contributors to Besu / AERE Network. * * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at @@ -51,13 +51,13 @@ import org.mockito.quality.Strictness; /** * THE COMPATIBILITY PROPERTY, which is the one that decides whether any of this can be shipped. * - *

        The three anchor patches plus the registry-binding arming gate are meant to travel onto the - * seven live boxes BEFORE the activation height, so that the fleet is already running the binary - * when the height arrives and activation is a restart-free event. That plan is only sound if a node - * holding this binary and NO {@code aere.pq.*} configuration is indistinguishable from one holding - * the binary it replaces: it must start, it must produce blocks, and it must not say a word about - * an anchor that is not armed. If that property is lost, the whole package is unusable regardless - * of how correct the anchor logic is, because it could not be staged. + *

        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. * *

        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 diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqParentHeightAlignmentTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqParentHeightAlignmentTest.java index 0067f4b..393cbea 100644 --- a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqParentHeightAlignmentTest.java +++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqParentHeightAlignmentTest.java @@ -38,7 +38,7 @@ import org.junit.jupiter.api.Test; 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. * *

        WHAT WAS MEASURED, on a public node synced from genesis on the live chain. {@code * PqAnchorSealsRule} judges the certificate carried by the block at the first anchor height H by @@ -111,7 +111,7 @@ public class PqParentHeightAlignmentTest { genesisPath = tmp.resolve("genesis-d228.json"); Files.writeString(genesisPath, manifest.toString()); // DELIBERATELY NOT setting aere.falcon.genesis: the head registry stays EMPTY, which is the - // public-node shape the defect was measured on. + // public-node shape D-228 was measured on. // A genuine Falcon-512 signature by index 0 over MESSAGE. final FalconSigner signer = new FalconSigner(); @@ -168,7 +168,7 @@ public class PqParentHeightAlignmentTest { final FalconSealSupport pqc = FalconSealSupport.instance(); assertThat(pqc.addressForIndexAtHistoric(H - 1L, 0)) .describedAs( - "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 " + "H, and it is VERIFIED; refusing here parks a syncing node at H-1 forever") .isEqualTo(validators.get(0)); diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqRegistryBindingTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqRegistryBindingTest.java index d902552..dae5cb9 100644 --- a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqRegistryBindingTest.java +++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqRegistryBindingTest.java @@ -47,8 +47,8 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; /** - * AERE REGISTRY BINDING. The registry says validator i has Falcon key k. Nothing said validator i - * ever agreed to that, or that anybody holds k's secret. + * AERE D-146. The registry says validator i has Falcon key k. Nothing said validator i ever agreed + * to that, or that anybody holds k's secret. * *

        WHAT WAS MEASURED BEFORE THIS TEST EXISTED, on the real verification path, with the startup * gate reporting MATCH and the header ACCEPTED every time: @@ -68,7 +68,7 @@ import org.junit.jupiter.api.io.TempDir; * *

        KEYS. Every Falcon and ECDSA key here is generated in memory, used inside one test method, and * never written anywhere but a JUnit temporary directory. Nothing in this file touches the key - * ceremony 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 { @@ -172,7 +172,7 @@ class PqRegistryBindingTest { // ceremony: the registry writer, who has every FALCON secret. It signs a perfectly valid // possession proof for key 0 sitting under validator 1's address. Only the ECDSA claim, which // needs validator 1's consensus key, stops it - and that is the whole argument for why a Falcon - // proof-of-possession alone does not repair the attribution gap. + // proof-of-possession alone does not repair D-146. final List rows = rows(); rows.get(0).address = holders.get(1).address(); rows.get(1).address = holders.get(3).address(); // keep addresses distinct @@ -490,8 +490,8 @@ class PqRegistryBindingTest { * Re-sign every row the way THE REGISTRY WRITER would at a key ceremony: it holds every FALCON * secret, so it can always produce a valid possession proof for whatever row it just wrote. The * ECDSA claim it can produce is the one belonging to the holder of the key on that row, never the - * one belonging to the address it filed the key under. That gap is the whole of the attribution - * problem this class exists for, and it is why the Falcon half alone repairs nothing. + * one belonging to the address it filed the key under. That gap is the whole of D-146 and it is + * why the Falcon half alone repairs nothing. */ private static void resignAsRegistryWriter(final List rows) { for (int i = 0; i < rows.size(); i++) { diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqRegistryHeightRefusalTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqRegistryHeightRefusalTest.java index 7efcb1a..2b7a89c 100644 --- a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqRegistryHeightRefusalTest.java +++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqRegistryHeightRefusalTest.java @@ -1,5 +1,5 @@ /* - * Copyright contributors to Besu / Aere Network. + * Copyright contributors to Besu / AERE Network. * * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at @@ -38,17 +38,16 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; /** - * KEY ROTATION AND REBINDING, the adversarial review of 2026-08-02, at the layer that actually - * answers the question. + * D2, the adversarial review of 2026-08-02, at the layer that actually answers the question. * - *

        WHAT THE REVIEW MEASURED. {@code PqSignerRegistry} had {@code addressForIndex(int)} and {@code + *

        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. * - *

        WHAT WAS REPAIRED BEFORE THIS FILE, AND WHAT WAS NOT. The height-indexed registry change gave - * the validation path {@code addressForIndexAt} / {@code verifyAt} and a height-indexed schedule. - * The measurement of 2026-08-05 found the repair INERT, for a reason that is one line long: with no + *

        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. @@ -95,8 +94,8 @@ public class PqRegistryHeightRefusalTest { @BeforeEach public void setUp() throws Exception { - // AERE REGISTRY BINDING (2026-08-06): v2, proof-bound, bound at H. See PqV2Fixture for why the - // addresses are derived from real secp256k1 keys and can no longer be spelled 0xA00+i. + // 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 @@ -169,11 +168,10 @@ public class PqRegistryHeightRefusalTest { // No schedule was ever loaded: verifyRegistryBindingOrAbort has not run, which is the state of // every node on chain 2800 today, because config.pqRegistryHash is in no genesis this fleet - // runs (measured 2026-08-05: a search across every deployment and monitoring configuration we - // hold returns nothing). + // runs (measured 2026-08-05, grep over deploy/ and monitoring/ returns nothing). assertThat(pqc.verifyAtHistoric(H, 0, MESSAGE, sealByIndexZero)) .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, " + "so one key rotation made every block above H unverifiable while the node " + "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 // design states: below H requiredHashAt is empty and the fallback is unreachable by anything // that decides a header. - // AERE REGISTRY BINDING (2026-08-06): the hash above is hashFor, not hashV1, because this - // fixture's registry is now v2 and hashes under a different domain tag. A schedule entry that - // names the v1 number names a registry this node does not hold. + // 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); diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqRegistryRotationTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqRegistryRotationTest.java index 7868c88..96debad 100644 --- a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqRegistryRotationTest.java +++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqRegistryRotationTest.java @@ -1,5 +1,5 @@ /* - * Copyright contributors to Besu / Aere Network. + * Copyright contributors to Besu / AERE Network. * * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at @@ -30,13 +30,12 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; /** - * SIGNER-REGISTRY ROTATION AND REVOCATION: the Falcon signer registry has no usable rotation and no - * usable revocation. + * D-081: the Falcon signer registry has no usable rotation and no usable revocation. * *

        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 - * as one where "a later entry expresses a key rotation". This file asks whether that sentence - * survives contact with the code that enforces it. + * {@code config.pqRegistryHash} is a SCHEDULE of {block, hash} entries, and the 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. * *

        The enforcement side is {@link PqRegistryHash#matchesAt} and, on the block path, {@code * 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 * one of them. * - *

        The consequence is not cosmetic and it is not confined to the rotation moment. The binding is - * enforced while history is being acquired, not only at the head, so the whole range of heights has - * to be satisfiable at once and not merely the current interval. That is the constraint the two - * measurements below are written against. + *

        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. * *

        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 @@ -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 * failure of the two measurements cannot be blamed on the fixture. */ +// The D-081 label is our internal finding id. It names a fact about this +// code, not anything outside it. public class PqRegistryRotationTest { private static final long CHAIN_ID = 2800L; @@ -172,7 +175,7 @@ public class PqRegistryRotationTest { } catch (final IOException 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. */ @@ -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 * satisfied by any node at all. */ @@ -263,7 +266,7 @@ public class PqRegistryRotationTest { final String json = "[{\"block\":" + H2 + ",\"hash\":\"0x" + h + "\"},{\"block\":" + H1 + ",\"hash\":\"0x" + h + "\"}]"; final JsonNode node = new ObjectMapper().readTree(json); - assertThatThrownBy(() -> PqRegistryHash.parseSchedule(node, "rotation fixture")) + assertThatThrownBy(() -> PqRegistryHash.parseSchedule(node, "D-081 fixture")) .isInstanceOf(PqRegistryHash.RegistryConfigException.class) .hasMessageContaining("STRICTLY INCREASING"); } @@ -303,10 +306,16 @@ public class PqRegistryRotationTest { assertThat(complete) .withFailMessage( - "ROTATION UNUSABLE: one scheduled rotation at height %d leaves NO node configuration " - + "that satisfies the registry binding at every enforced height. %s. A rotation " - + "scheme must keep the whole HEIGHT-TO-KEY-SET mapping loadable, not only the " - + "current entry.", + "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(); @@ -345,9 +354,10 @@ public class PqRegistryRotationTest { assertThat(complete) .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 " - + "revocation is expressible and is not usable.", + + "revocation is expressible and is not usable: performing it costs the ability to " + + "acquire the chain.", H2, String.join("; ", report)) .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 // 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 registry-binding block). If that route - // were broken + // (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". // @@ -453,9 +462,10 @@ public class PqRegistryRotationTest { } assertThat(refused) .withFailMessage( - "ROTATION UNUSABLE on the route an operator can actually take: the history list %s " - + "parses and loads, and the resulting set is still refused at %s. The library can " - + "express the whole height-to-key-set mapping but the configuration string cannot reach " + "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(); diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqSchemeScheduleTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqSchemeScheduleTest.java new file mode 100644 index 0000000..41e03e8 --- /dev/null +++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqSchemeScheduleTest.java @@ -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)); + } +} diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqSealPersistenceTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqSealPersistenceTest.java index 8967ff3..a9f4149 100644 --- a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqSealPersistenceTest.java +++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqSealPersistenceTest.java @@ -1,5 +1,5 @@ /* - * Copyright contributors to Besu / Aere Network. + * Copyright contributors to Besu / AERE Network. * * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at @@ -56,15 +56,15 @@ import org.junit.jupiter.api.io.TempDir; import org.mockito.quality.Strictness; /** - * 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. * *

        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 - * network. The FIRST half of that deadlock was the registry, repaired the same day: it now - * activates at start-up from chain-head state, and all seven nodes reported - * "anchor activation at STARTUP from head state: SUCCEEDED". The chain died anyway. The refusal - * only changed shape, from "registry address-bound=false" to "registry address-bound=TRUE ... - * Heard 0 seal(s)", frozen 150 s then 298 s. + * restart of all seven validators kills the chain permanently (rehearsal + * repetitie-activare-2026-08-05, isolated chain 330858). The FIRST half of that deadlock was the + * registry, repaired the same day: it now activates at start-up from chain-head state, and all + * seven nodes reported "anchor activation at STARTUP from head state: SUCCEEDED". The chain died + * anyway. The refusal only changed shape, from "registry address-bound=false" to "registry + * address-bound=TRUE ... Heard 0 seal(s)", frozen 150 s then 298 s. * *

        THE SECOND CIRCLE. The Falcon seals over M(head) travel on nothing but the Commit messages of * the head block, and those are never replayed after a restart. They exist nowhere else: the head's @@ -100,6 +100,8 @@ import org.mockito.quality.Strictness; * real simultaneous restart with a binary built from this tree. That needs the rehearsal network and * is separate evidence. This class measures every decision that recovery depends on. */ +// The D-141 label is our internal finding id. It names a fact about this +// code, not anything outside it. public class PqSealPersistenceTest { /** Anchor activation height H. */ @@ -111,7 +113,7 @@ public class PqSealPersistenceTest { /** Height from which the staged threshold K is in force. */ 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 N = 7; @@ -136,7 +138,7 @@ public class PqSealPersistenceTest { public void setUp() throws Exception { 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 StringBuilder manifest = 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 * 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 - - * state believed because it sits in a file a node can be pointed at. + * 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. * *

        Three shapes of forgery are in the one file, because "a forged seal" is not one thing: * diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqSignedHeightTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqSignedHeightTest.java index fc1333f..8122465 100644 --- a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqSignedHeightTest.java +++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqSignedHeightTest.java @@ -36,7 +36,7 @@ import org.junit.jupiter.api.Test; 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. * *

        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; * *

        WHAT WAS ACTUALLY TRUE UNTIL THIS FILE. {@code bindHeight} was never compared with the {@code * block} of the schedule entry that puts the registry in force. Not anywhere. The two numbers had - * been in the same lexical scope ever since the schedule became height-indexed, and were never put - * on the same expression. + * been in the same lexical scope since D-081 and were never put on the same expression. * *

        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 @@ -297,7 +296,7 @@ public class PqSignedHeightTest { // The honest limitation has to be IN the message, or an operator will read this as a // consensus guarantee it is not. .hasMessageContaining("DETECTION on this node only") - .hasMessageContaining("all seven nodes and in the same change"); + .hasMessageContaining("on every node and in the same change"); } @Test diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqStartupHistoryTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqStartupHistoryTest.java index 705838d..4826779 100644 --- a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqStartupHistoryTest.java +++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqStartupHistoryTest.java @@ -33,7 +33,7 @@ import org.junit.jupiter.api.Test; 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. * *

        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; *

        THE DEFECT WAS THE ORDER OF TWO BLOCKS OF CODE. {@code * FalconSealSupport.verifyRegistryBindingOrAbort} loaded ONE registry, the primary, and handed it to * the guard. The history list was read FORTY-ONE LINES FURTHER DOWN, to build the height-resolved - * set the height-indexed registry change introduced. So the refusal was thrown before the code that - * knew the answer had run. The guard was not wrong about what it compared; it was never shown the - * other files. + * set D-081 introduced. So the refusal was thrown before the code that knew the answer had run. The + * guard was not wrong about what it compared; it was never shown the other files. * *

        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 diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqV2Fixture.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqV2Fixture.java index 2c7108f..4fb9529 100644 --- a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqV2Fixture.java +++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqV2Fixture.java @@ -37,13 +37,13 @@ import org.bouncycastle.pqc.crypto.falcon.FalconPublicKeyParameters; import org.bouncycastle.pqc.crypto.falcon.FalconSigner; /** - * AERE REGISTRY BINDING (2026-08-06). The shared probe fleet every arming fixture is now built - * from, and the reason it had to exist. + * AERE D-146 (2026-08-06). The shared probe fleet every arming fixture is now built from, and the + * reason it had to exist. * *

        WHAT IT REPLACED, and why the replacement is not cosmetic. Until 2026-08-06 seven separate * fixtures built their registries around addresses spelled {@code String.format("0x%040x", 0xA00 + * i)}. Those addresses are arithmetic, not keys: no secp256k1 private key produces them, so no - * validator can ever sign a 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, * and provably unable to produce the registry the arming path now requires. Measured on 2026-08-06: * 35 tests across 7 classes, every failure carrying AERE-PQC-REG-ARM-02. @@ -215,9 +215,8 @@ public final class PqV2Fixture { /** * The bytes the genesis anchor slot commits to for row {@code i}: {@code address || publicKey}, - * which is what {@code hashV0Legacy} accumulates. Unchanged by the v2 binding work - the proofs - * are outside the legacy pre-image - and kept here so a fixture cannot drift from the row it just - * wrote. + * which is what {@code hashV0Legacy} accumulates. Unchanged by D-146 - the proofs are outside the + * legacy pre-image - and kept here so a fixture cannot drift from the row it just wrote. * * @param i the row index * @return the anchored pre-image bytes for that row diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PreparePqAttachGateTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PreparePqAttachGateTest.java new file mode 100644 index 0000000..5168978 --- /dev/null +++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PreparePqAttachGateTest.java @@ -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). + * + *

        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. + * + *

        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(); + } +} diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/SealSchemeAgilityTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/SealSchemeAgilityTest.java new file mode 100644 index 0000000..91ca9bb --- /dev/null +++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/SealSchemeAgilityTest.java @@ -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 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); + } +} diff --git a/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/network/QbftMessageTransmitter.java b/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/network/QbftMessageTransmitter.java index b0ba9ea..3345a92 100644 --- a/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/network/QbftMessageTransmitter.java +++ b/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/network/QbftMessageTransmitter.java @@ -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.FalconSeal; +import org.hyperledger.besu.consensus.common.bft.SchemeSeal; import org.hyperledger.besu.consensus.common.bft.network.ValidatorMulticaster; import org.hyperledger.besu.consensus.common.bft.payload.SignedData; import org.hyperledger.besu.consensus.qbft.core.messagedata.CommitMessageData; @@ -103,8 +104,27 @@ public class QbftMessageTransmitter { * @param digest the digest */ public void multicastPrepare(final ConsensusRoundIdentifier roundIdentifier, final Hash digest) { + multicastPrepare(roundIdentifier, digest, Optional.empty()); + } + + /** + * Multicast a prepare carrying an OPTIONAL post-quantum seal of this node. + * + *

        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) { try { - final Prepare data = messageFactory.createPrepare(roundIdentifier, digest); + final Prepare data = messageFactory.createPrepare(roundIdentifier, digest, falconSeal); final PrepareMessageData message = PrepareMessageData.create(data); @@ -141,8 +161,28 @@ public class QbftMessageTransmitter { final Hash digest, final SECPSignature commitSeal, final Optional 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, + final java.util.List extraSeals) { 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); diff --git a/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/CommitPayload.java b/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/CommitPayload.java index e55efb5..8b788f4 100644 --- a/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/CommitPayload.java +++ b/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/CommitPayload.java @@ -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.FalconSeal; +import org.hyperledger.besu.consensus.common.bft.PqAnchorV2; +import org.hyperledger.besu.consensus.common.bft.SchemeSeal; +import org.hyperledger.besu.consensus.common.bft.SealSchemes; import org.hyperledger.besu.consensus.common.bft.payload.Payload; import org.hyperledger.besu.consensus.qbft.core.messagedata.QbftV1; import org.hyperledger.besu.crypto.SECPSignature; @@ -31,6 +34,7 @@ import org.hyperledger.besu.ethereum.rlp.RLPException; import org.hyperledger.besu.ethereum.rlp.RLPInput; import org.hyperledger.besu.ethereum.rlp.RLPOutput; +import java.util.List; import java.util.Objects; import java.util.Optional; import java.util.StringJoiner; @@ -56,6 +60,10 @@ public class CommitPayload extends QbftPayload { private final Hash digest; private final SECPSignature commitSeal; private final Optional 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 extraSeals; /** * Instantiates a new Commit payload (no Falcon seal). @@ -84,10 +92,72 @@ public class CommitPayload extends QbftPayload { final Hash digest, final SECPSignature commitSeal, final Optional falconSeal) { + this(roundIdentifier, digest, commitSeal, falconSeal, List.of()); + } + + /** + * Instantiates a new Commit payload carrying a HYBRID post-quantum certificate. + * + *

        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: + * + *

          + *
        • 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; + *
        • 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; + *
        • a signature has exactly ONE home, so the two slots can never disagree about Falcon. + *
        + * + *

        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. + * + *

        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, + final List extraSeals) { this.roundIdentifier = roundIdentifier; this.digest = digest; this.commitSeal = commitSeal; this.falconSeal = falconSeal == null ? Optional.empty() : falconSeal; + this.extraSeals = extraSeals == null ? List.of() : List.copyOf(extraSeals); + if (!this.extraSeals.isEmpty()) { + // The wire format cannot even REPRESENT extras without a Falcon seal, because the slots are + // told apart by count. Refusing here means an object that could not be written correctly + // cannot be built at all, instead of failing later at encode time on the consensus path. + if (this.falconSeal.isEmpty()) { + throw new IllegalArgumentException( + "AERE HIBRID: extra scheme seals require the Falcon seal to be present"); + } + for (final SchemeSeal seal : this.extraSeals) { + if (seal.getSchemeWireId() == SealSchemes.FALCON_512.wireId()) { + throw new IllegalArgumentException( + "AERE HIBRID: Falcon belongs in its own slot, not in the extras"); + } + } + // Validates canonicality and the seal cap NOW, so a payload that cannot be encoded cannot + // exist. PqAnchorV2.encode throws on a non-canonical or oversized certificate. + final Bytes unused = PqAnchorV2.encode(this.extraSeals); + if (unused.isEmpty()) { + throw new IllegalArgumentException("AERE HIBRID: empty encoding of a non-empty certificate"); + } + } } /** @@ -149,9 +219,37 @@ public class CommitPayload extends QbftPayload { payloadRlp.leaveList(); falconSeal = Optional.of(new FalconSeal(idx, sig)); } + + // AERE HIBRID: a SECOND optional element, the non-Falcon scheme seals. Unambiguous by count: + // it can only be here if the Falcon element above was already consumed, so the two slots can + // never be confused for one another and no structural sniffing is required. + List extraSeals = List.of(); + if (!payloadRlp.isEndOfCurrentList()) { + final Bytes extrasRaw = payloadRlp.readAsRlp().raw(); + try { + extraSeals = PqAnchorV2.decode(extrasRaw); + } catch (final RuntimeException e) { + // A malformed certificate is a malformed MESSAGE. It must surface as an RLP failure so the + // gossip layer drops it like any other undecodable commit, never as an unchecked throw on + // the consensus path. + throw new RLPException("AERE HIBRID: undecodable extra certificate: " + e.getMessage()); + } + if (extraSeals.isEmpty()) { + // An empty extras element and an absent one would be two encodings of the same value. + throw new RLPException("AERE HIBRID: empty extra certificate must be absent, not empty"); + } + } payloadRlp.leaveList(); - final CommitPayload payload = 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 // 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.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(); } @@ -223,6 +327,15 @@ public class CommitPayload extends QbftPayload { return falconSeal; } + /** + * Gets the non-Falcon scheme seals of a hybrid certificate. + * + * @return the extra seals, empty for every commit the live fleet emits today + */ + public List getExtraSeals() { + return extraSeals; + } + @Override public ConsensusRoundIdentifier getRoundIdentifier() { return roundIdentifier; @@ -240,12 +353,13 @@ public class CommitPayload extends QbftPayload { return Objects.equals(roundIdentifier, that.roundIdentifier) && Objects.equals(digest, that.digest) && Objects.equals(commitSeal, that.commitSeal) - && Objects.equals(falconSeal, that.falconSeal); + && Objects.equals(falconSeal, that.falconSeal) + && Objects.equals(extraSeals, that.extraSeals); } @Override public int hashCode() { - return Objects.hash(roundIdentifier, digest, commitSeal, falconSeal); + return Objects.hash(roundIdentifier, digest, commitSeal, falconSeal, extraSeals); } @Override @@ -255,6 +369,7 @@ public class CommitPayload extends QbftPayload { .add("digest=" + digest) .add("commitSeal=" + commitSeal) .add("falconSeal=" + falconSeal) + .add("extraSeals=" + extraSeals.size()) .toString(); } } diff --git a/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/MessageFactory.java b/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/MessageFactory.java index 7d0e371..dddb933 100644 --- a/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/MessageFactory.java +++ b/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/MessageFactory.java @@ -107,7 +107,27 @@ public class MessageFactory { * @return the prepare */ 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. + * + *

        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) { + final PreparePayload payload = new PreparePayload(roundIdentifier, digest, falconSeal); return new Prepare(createSignedMessage(payload)); } @@ -141,8 +161,29 @@ public class MessageFactory { final Hash digest, final SECPSignature commitSeal, final Optional 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, + final java.util.List extraSeals) { final CommitPayload payload = - new CommitPayload(roundIdentifier, digest, commitSeal, falconSeal); + new CommitPayload(roundIdentifier, digest, commitSeal, falconSeal, extraSeals); return new Commit(createSignedMessage(payload)); } diff --git a/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/PreparePayload.java b/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/PreparePayload.java new file mode 100644 index 0000000..5533ef6 --- /dev/null +++ b/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/PreparePayload.java @@ -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. + * + *

        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 byte for byte as upstream, + * and that is precisely the property that lets the binary be rolled onto a live fleet without a + * flag day. + * + *

        NOTHING EMITS SUCH A PREPARE YET. 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. + * + *

        What the seal signs is NOT this file's business, 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; + + /** + * 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) { + this.roundIdentifier = roundIdentifier; + this.digest = digest; + this.falconSeal = falconSeal == null ? Optional.empty() : falconSeal; + } + + /** + * Read from rlp input and return prepare payload. + * + *

        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 = 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 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(); + } +} diff --git a/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/QbftController.java b/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/QbftController.java new file mode 100644 index 0000000..ba338c5 --- /dev/null +++ b/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/QbftController.java @@ -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 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 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 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 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

        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

        > void consumeMessage( + final QbftMessage message, + final P bftMessage, + final Consumer

        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. + * + *

        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. + * + *

        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 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; + } +} diff --git a/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/QbftRound.java b/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/QbftRound.java index 054b9c3..a1103f6 100644 --- a/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/QbftRound.java +++ b/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/QbftRound.java @@ -282,16 +282,52 @@ public class QbftRound { private void sendPrepare(final QbftBlock block) { LOG.debug("Sending prepare message. round={}", roundState.getRoundIdentifier()); try { + // AERE PQ PREPARE (2026-08-28), step 2: the seal is computed EXACTLY ONCE here and is + // trece si exemplarului local si celui de pe fir. Semnaturile Falcon sunt randomizate, deci + // doua semnari ale aceluiasi mesaj dau doi octeti diferiti; daca fiecare exemplar si-ar semna + // its own, the same validator would produce two valid and DIFFERENT PREPAREs for the same + // round. The gate is closed on every node today, so this is empty until a decision. + final Optional falconSeal = 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 = - messageFactory.createPrepare(getRoundIdentifier(), block.getHash()); + falconSeal.isPresent() + ? messageFactory.createPrepare(getRoundIdentifier(), block.getHash(), falconSeal) + : messageFactory.createPrepare(getRoundIdentifier(), block.getHash()); peerIsPrepared(localPrepareMessage); - transmitter.multicastPrepare( - localPrepareMessage.getRoundIdentifier(), localPrepareMessage.getDigest()); + if (falconSeal.isPresent()) { + transmitter.multicastPrepare( + localPrepareMessage.getRoundIdentifier(), localPrepareMessage.getDigest(), falconSeal); + } else { + transmitter.multicastPrepare( + localPrepareMessage.getRoundIdentifier(), localPrepareMessage.getDigest()); + } } catch (final SecurityModuleException e) { LOG.warn("Failed to create a signed Prepare; {}", e.getMessage()); } } + /** + * Sigiliul post-cuantic al acestui nod pentru PREPARE-ul blocului dat, sau gol. + * + *

        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 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. * @@ -347,11 +383,17 @@ public class QbftRound { return true; } final Optional falconSeal = falconSealFor(block, commitHash); + // AERE HYBRID: the other schemes' extras, over the SAME message; empty on any node today. + final java.util.List extraSeals = + falconSeal.isPresent() ? extraSealsFor(block, commitHash) : java.util.List.of(); // There are times handling a proposed block is enough to enter prepared. if (wasPrepared != roundState.isPrepared()) { LOG.debug("Sending commit message. round={}", roundState.getRoundIdentifier()); - if (falconSeal.isPresent()) { + if (!extraSeals.isEmpty()) { + transmitter.multicastCommit( + getRoundIdentifier(), block.getHash(), commitSeal, falconSeal, extraSeals); + } else if (falconSeal.isPresent()) { transmitter.multicastCommit(getRoundIdentifier(), block.getHash(), commitSeal, falconSeal); } else { transmitter.multicastCommit(getRoundIdentifier(), block.getHash(), commitSeal); @@ -368,7 +410,8 @@ public class QbftRound { roundState.getRoundIdentifier(), msg.getBlock().getHash(), commitSeal, - falconSeal) + falconSeal, + extraSeals) : messageFactory.createCommit( roundState.getRoundIdentifier(), msg.getBlock().getHash(), commitSeal); roundState.addCommitMessage(localCommitMessage); @@ -398,7 +441,13 @@ public class QbftRound { final Hash commitHash = commitHashFor(block); final SECPSignature commitSeal = nodeKey.sign(Bytes32.wrap(commitHash.getBytes())); final Optional 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 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); } else { transmitter.multicastCommit(getRoundIdentifier(), block.getHash(), commitSeal); @@ -433,8 +482,14 @@ public class QbftRound { private void importBlockToChain() { // 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 - // (Falcon disabled), fall back to the unchanged ECDSA-only sealing path. + // assembler can embed the post-quantum certificate. When no Falcon seals were gossiped (Falcon + // disabled), fall back to the unchanged ECDSA-only sealing path. + // + // WHAT GETS EMBEDDED, corrected 2026-08-19 (finding D-235): NOT a per-block 2f+1 quorum. The + // assembler writes a certificate only at an anchor height, over the PARENT, and it needs at + // least K valid seals, K being the configured schedule (6 of 9 on chain 2800 today). Between + // anchor heights nothing is written. The older wording here said "a >= 2f+1 Falcon quorum + // certificate" and described the legacy per-block rule, which is retired at the anchor block. final Collection falconSeals = roundState.getFalconSeals(); final QbftBlock blockToImport = falconSeals.isEmpty() @@ -509,6 +564,31 @@ public class QbftRound { return pqOnchainHash; } + /** + * AERE HIBRID (2026-08-25): the non-Falcon seals of this node's hybrid certificate, over the + * SAME message the Falcon seal signs (the two are one certificate; two messages would be two + * certificates and the verifier could not bind them). Empty on every node that is not + * hybrid-configured, and below the emission gate: {@link HybridSealProducer} never throws and + * never emits half a certificate. + */ + private java.util.List 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 falconSealFor(final QbftBlock block, final Hash commitHash) { // FalconSealSupport.sign never throws (any fault is swallowed and logged), and returns empty // when this node holds no Falcon signing key, so the ECDSA commit path is never affected. @@ -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 // the same configured H; a node configured with a different H emits seals nobody can use, and // the producer drops them on verification rather than carrying them into a header. + // AERE HIBRID (2026-08-25): mesajul se calculeaza acum intr-UN singur loc, pqSealMessageFor, + // impartit cu extrasele hibride; doua copii ale acestei logici ar fi divergat intr-o zi. final long blockNumber = block.getHeader().getNumber(); - final Bytes32 message; - if (PqAnchorProducer.sealMessageIsAnchorForm(blockNumber)) { - message = - PqAnchor.commitMessage( - PqAnchorProducer.config().chainId(), blockNumber, pqOnchainHashOf(block).getBytes()); - } else { - message = Bytes32.wrap(commitHash.getBytes()); - } - return FalconSealSupport.instance().sign(blockNumber, message); + return FalconSealSupport.instance().sign(blockNumber, pqSealMessageFor(block, commitHash)); } private QbftBlock createCommitBlock(final QbftBlock block) { diff --git a/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/CommitValidator.java b/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/CommitValidator.java new file mode 100644 index 0000000..ca7ede6 --- /dev/null +++ b/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/CommitValidator.java @@ -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

        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. + * + *

        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

        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. + * + *

        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

        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 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 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; + } +} diff --git a/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/PqCommitEnforcement.java b/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/PqCommitEnforcement.java new file mode 100644 index 0000000..fdcce86 --- /dev/null +++ b/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/PqCommitEnforcement.java @@ -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. + * + *

        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. + * + *

        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. + * + *

        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 refusal( + final long height, + final Address author, + final Hash commitDigest, + final Optional 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 refusal( + final long height, + final Address author, + final Hash commitDigest, + final Optional seal, + final List extraSeals) { + final Optional 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 hybridRefusal( + final long height, + final Address author, + final Hash commitDigest, + final FalconSeal falconSeal, + final List extraSeals) { + final Set 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 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 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 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 falconRefusal( + final long height, + final Address author, + final Hash commitDigest, + final Optional 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(); + } +} diff --git a/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/PqPrepareEnforcement.java b/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/PqPrepareEnforcement.java new file mode 100644 index 0000000..c95fdf6 --- /dev/null +++ b/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/PqPrepareEnforcement.java @@ -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. + * + *

        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. + * + *

        WHY THIS IS A DIFFERENT LAYER FROM COMMIT, AND MORE DANGEROUS. 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. + * + *

        WHAT THE SEAL SIGNS, AND WHY NOT THE SAME THING AS COMMIT. 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. + * + *

        DISARMED BY DEFAULT. 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. + * + *

        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 refusal( + final long height, + final int round, + final Address author, + final Hash digest, + final Optional 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(); + } +} diff --git a/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/PrepareValidator.java b/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/PrepareValidator.java new file mode 100644 index 0000000..460ffe8 --- /dev/null +++ b/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/PrepareValidator.java @@ -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. + * + *

        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

        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
        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
        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 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 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; + } +} diff --git a/anchor/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/payload/CommitPayloadHybridTest.java b/anchor/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/payload/CommitPayloadHybridTest.java new file mode 100644 index 0000000..ec0f884 --- /dev/null +++ b/anchor/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/payload/CommitPayloadHybridTest.java @@ -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"); + } +} diff --git a/anchor/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/payload/PreparePayloadPqTest.java b/anchor/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/payload/PreparePayloadPqTest.java new file mode 100644 index 0000000..d2f91c4 --- /dev/null +++ b/anchor/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/payload/PreparePayloadPqTest.java @@ -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. + * + *

        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. + * + *

        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)); + } +} diff --git a/anchor/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/statemachine/PqLateSealSalvageTest.java b/anchor/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/statemachine/PqLateSealSalvageTest.java new file mode 100644 index 0000000..82586ae --- /dev/null +++ b/anchor/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/statemachine/PqLateSealSalvageTest.java @@ -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. + * + *

        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 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 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 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(); + } +} diff --git a/anchor/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/CommitValidatorPqEnforcementTest.java b/anchor/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/CommitValidatorPqEnforcementTest.java new file mode 100644 index 0000000..b326919 --- /dev/null +++ b/anchor/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/CommitValidatorPqEnforcementTest.java @@ -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 bindings = new HashMap<>(); + private final Map cheiPublice = new HashMap<>(); + private final Map 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(); + } +} diff --git a/anchor/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqCommitEnforcementTest.java b/anchor/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqCommitEnforcementTest.java new file mode 100644 index 0000000..8124b0c --- /dev/null +++ b/anchor/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqCommitEnforcementTest.java @@ -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 bindings; + final Map keys; + + RegistruDeTest(final Map bindings, final Map 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 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 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 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 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 refusal = + l.enforcement().refusal(H_ARMARE, VALIDATOR_0, altDigest, Optional.of(l.sigiliuValid0())); + assertThat(refusal).isPresent(); + assertThat(refusal.get()).contains("does NOT verify"); + } +} diff --git a/anchor/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqCommitPlumbingTest.java b/anchor/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqCommitPlumbingTest.java new file mode 100644 index 0000000..c697e69 --- /dev/null +++ b/anchor/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqCommitPlumbingTest.java @@ -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); + } + } +} diff --git a/anchor/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqHybridEnforcementTest.java b/anchor/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqHybridEnforcementTest.java new file mode 100644 index 0000000..2606f12 --- /dev/null +++ b/anchor/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqHybridEnforcementTest.java @@ -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 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 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 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 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 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 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(); + } +} diff --git a/anchor/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqPrepareAgilityTest.java b/anchor/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqPrepareAgilityTest.java new file mode 100644 index 0000000..ce7fb37 --- /dev/null +++ b/anchor/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqPrepareAgilityTest.java @@ -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. + * + *

        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 where exactly the nail is: in the enforcement class, or only + * in the wiring? + * + *

        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 without touching one line of consensus + * code. 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. + * + *

        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. + * + *

        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 bindings; + private final Map keys; + + RegistryPerScheme( + final SealScheme scheme, final Map bindings, final Map 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 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 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()); + } +} diff --git a/anchor/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqPrepareEnforcementTest.java b/anchor/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqPrepareEnforcementTest.java new file mode 100644 index 0000000..620f071 --- /dev/null +++ b/anchor/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqPrepareEnforcementTest.java @@ -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. + * + *

        What is proven here, each item closing one way of being wrong: + * + *

          + *
        1. below the arming height NOTHING changes - the condition for the binary to sit on the fleet; + *
        2. above it, a PREPARE without a seal does NOT count; + *
        3. a seal from ANOTHER validator does not vouch for this author; + *
        4. 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; + *
        5. a seal from a DIFFERENT ROUND does not pass; + *
        6. a mistyped configuration REFUSES, it does not disarm. + *
        + * + *

        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 bindings; + final Map keys; + + RegistruDeTest(final Map bindings, final Map 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 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 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 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 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 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"); + } +} diff --git a/anchor/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PrepareValidatorPqWiringTest.java b/anchor/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PrepareValidatorPqWiringTest.java new file mode 100644 index 0000000..4ea938d --- /dev/null +++ b/anchor/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PrepareValidatorPqWiringTest.java @@ -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? + * + *

        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". + * + *

        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(); + } +} diff --git a/anchor/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/RoundChangeJustificationPqTest.java b/anchor/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/RoundChangeJustificationPqTest.java new file mode 100644 index 0000000..848ad4c --- /dev/null +++ b/anchor/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/RoundChangeJustificationPqTest.java @@ -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. + * + *

        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: + * + *

          + *
        1. 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; + *
        2. the coupling already exists: {@code RoundChangeMessageValidator} builds a + * {@link PrepareValidator} with the three-argument constructor, and that one wires its own + * enforcement from the system configuration. + *
        + * + *

        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. + * + *

        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> 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(); + } +} diff --git a/anchor/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/QbftBlockHeaderValidationRulesetFactory.java b/anchor/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/QbftBlockHeaderValidationRulesetFactory.java index 6c3fba5..cfc67a9 100644 --- a/anchor/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/QbftBlockHeaderValidationRulesetFactory.java +++ b/anchor/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/QbftBlockHeaderValidationRulesetFactory.java @@ -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.headervalidationrules.BftCoinbaseValidationRule; import org.hyperledger.besu.consensus.common.bft.headervalidationrules.BftCommitSealsValidationRule; +import org.hyperledger.besu.consensus.qbft.headervalidationrules.AereBaseFeeImportRule; import org.hyperledger.besu.consensus.qbft.headervalidationrules.FalconSealValidationRule; import org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorDigestAttachedRule; import org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorDigestRule; @@ -102,12 +103,11 @@ public class QbftBlockHeaderValidationRulesetFactory { * therefore bit-for-bit equivalent to today's on the whole existing chain, which is the condition * for warming it on a live node. * - *

        The rule COUNT goes from 11 to 16 (the per-block registry binding adds one, was 13; - * SINCRONIZARE adds the attached copy of the digest rule, was 14; OPTIUNI-URGENTA adds the - * emergency announcement rule, which can never reject, was 15) (14 unconditional plus the - * conditional timestamp rule). The original design note said 12 because it assumed the legacy - * Falcon rule would be deleted; retiring it by height instead is what keeps behaviour below H - * identical, so it stays in the list. + *

        The rule COUNT goes from 11 to 16 (A8 adds the registry-binding rule, was 13; SINCRONIZARE + * adds the attached copy of the digest rule, was 14; OPTIUNI-URGENTA adds the emergency + * announcement rule, which can never reject, was 15) (14 unconditional plus the conditional timestamp rule). The + * design note said 12 because it assumed the legacy Falcon rule would be deleted; retiring it by + * height instead is what keeps behaviour below H identical, so it stays in the list. * * @param minimumTimeBetweenBlocks the minimum amount of time that must elapse between blocks. * @param useValidatorContract whether validator selection is using a validator contract @@ -143,6 +143,16 @@ public class QbftBlockHeaderValidationRulesetFactory { new GasLimitRangeAndDeltaValidationRule( DEFAULT_MIN_GAS_LIMIT, DEFAULT_MAX_GAS_LIMIT, baseFeeMarket)) .addRule(new TimestampBoundedByFutureParameter(1)) + // AERE D-AMONTE-02: the base-fee rule every non-BFT factory in this jar wires and the + // QBFT one upstream forgot. Height-gated (disarmed = today's behaviour, byte for byte): + // the chain's HISTORY contains blocks this validation would reject (the two floor-less + // days, the lost-threshold window where the fee was not a function of the parent), so + // it must never look below its arming height. Delegates to THIS node's fee market, so + // the AERE 1 Gwei floor is validated too - the check that closes the empty-block + // one-wei divergence measured on the mixed network (STARE-PRODUCATOR 1bis). + .addRule( + new AereBaseFeeImportRule( + AereBaseFeeImportRule.armedFromSystemConfig(), baseFeeMarket)) .addRule( new ConstantFieldValidationRule<>( "MixHash", BlockHeader::getMixHash, BftHelpers.EXPECTED_MIX_HASH)) @@ -173,8 +183,8 @@ public class QbftBlockHeaderValidationRulesetFactory { .addRule(new PqAnchorDigestAttachedRule(pqAnchorConfig)) // AERE ANCORA-V2 R2: attached, full validation only. .addRule(new PqAnchorSealsRule(pqAnchorConfig)) - // AERE REGISTRY BINDING, per-block half: the registry this node runs must be the - // registry config.pqRegistryHash requires AT THIS HEIGHT. The startup guard answers + // AERE A8 per-block half: the registry this node runs must be the registry + // config.pqRegistryHash requires AT THIS HEIGHT. The startup guard answers // that once, against the head that existed at startup; a rotation entry in // the schedule can pass underneath a running node and never be noticed. // Inert when no schedule is configured, which is chain 2800 today. diff --git a/anchor/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/adaptor/QbftBlockCreatorAdaptor.java b/anchor/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/adaptor/QbftBlockCreatorAdaptor.java index 2151f57..93467e1 100644 --- a/anchor/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/adaptor/QbftBlockCreatorAdaptor.java +++ b/anchor/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/adaptor/QbftBlockCreatorAdaptor.java @@ -161,7 +161,7 @@ public class QbftBlockCreatorAdaptor implements QbftBlockCreator { final Hash commitHash = 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 // the assembler never embeds a seal the header rule would later reject as ineligible. final Set

        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 - // every validator attaching, this assembler wrote FIVE seals into EVERY header, taking it from - // 525 to 3844 bytes. That is roughly SEVEN TIMES the header bytes stored per block, on every - // node, for as long as the chain runs, which is what makes the interval a design constraint - // and not a tuning knob. + // AERE DISC 2026-08-08: the interval gate. Measured on chain 2800 the same day: with all + // seven validators attaching, this assembler wrote FIVE seals into EVERY header, 525 -> 3844 + // bytes, about 200 GB per node per year against 12 GB free on the tightest host. // // 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 @@ -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 // certificate is still produced. if (quorumCert.isEmpty() && pqc.signingEnabled()) { - // REGISTRY HEIGHT BINDING (2026-08-06): this is the ONE registry question in the stack with - // no honest height - "am I, right now, an eligible signer", asked before signing with the - // single private key this process holds. It gets its own name rather than a fabricated - // height, so that no future reader mistakes it for a verification path. See - // FalconSealSupport#localSigningAddress. + // D2 (2026-08-06): this is the ONE registry question in the stack with no honest height - + // "am I, right now, an eligible signer", asked before signing with the single private key + // this process holds. It gets its own name rather than a fabricated height, so that no + // future reader mistakes it for a verification path. See FalconSealSupport#localSigningAddress. final Address self = pqc.localSigningAddress(); if (self != null && eligible.contains(self)) { // 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); } - // REGISTRY HEIGHT BINDING (2026-08-06): takes the height of the block being sealed. The seals - // gathered here are over THIS block's committed-seal hash, so the height is this block's own and - // is known at the call site. It matters at exactly one moment - a rotation height - where - // assembling a certificate under the head key set while every validator checks it under the - // scheduled one produces a block every other node rejects, with nothing in any log naming the - // reason. + // D2 (2026-08-06): takes the height of the block being sealed. The seals gathered here are over + // THIS block's committed-seal hash, so the height is this block's own and is known at the call + // site. It matters at exactly one moment - a rotation height - where assembling a certificate + // under the head key set while every validator checks it under the scheduled one produces a block + // the fleet rejects, with nothing in any log naming the reason. private static List verifiedDistinctSeals( final FalconSealSupport pqc, 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 // 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()); if (signer == null || !eligible.contains(signer) || seen.contains(signer)) { continue; diff --git a/anchor/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/AereBaseFeeImportRule.java b/anchor/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/AereBaseFeeImportRule.java new file mode 100644 index 0000000..6741e29 --- /dev/null +++ b/anchor/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/AereBaseFeeImportRule.java @@ -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= (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) { + 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; + } +} diff --git a/anchor/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/FalconSealValidationRule.java b/anchor/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/FalconSealValidationRule.java index b09edac..1b8bed1 100644 --- a/anchor/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/FalconSealValidationRule.java +++ b/anchor/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/FalconSealValidationRule.java @@ -1,5 +1,5 @@ /* - * Copyright contributors to Besu / Aere Network. + * Copyright contributors to Besu / AERE Network. * * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at @@ -43,12 +43,27 @@ import org.slf4j.LoggerFactory; /** * Verifies the PARALLEL Falcon-512 post-quantum QUORUM CERTIFICATE embedded in a QBFT block header. * + *

        RETIRED ON CHAIN 2800, AND EVERYTHING BELOW DESCRIBES A RULE THAT NO LONGER APPLIES THERE + * (finding D-235, corrected 2026-08-19). 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. + * + *

        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. + * *

        A Falcon quorum certificate is the set of Falcon-512 seals gossiped by validators on their QBFT * commit messages (each a signature over the same commit hash the ECDSA committed seal signs), * aggregated by the block assembler into the header's parallel Falcon-seal list. * - *

        AERE audit fix, ELIGIBLE-SIGNER BINDING (2026-07-18). Both the Falcon quorum threshold - * AND the counted-seal set are now bound to ONE well-defined set: + *

        AERE audit fix (AUD-CONSENSUS-1 / AUD-CONSENSUS-2, 2026-07-18). Both the Falcon quorum + * threshold AND the counted-seal set are now bound to ONE well-defined set: * *

          *   eligibleSigners = currentValidators (getValidatorsAfterBlock(parent))
        @@ -87,13 +102,12 @@ import org.slf4j.LoggerFactory;
          *       boundary, i.e. arm time), NEVER an implicit accept.
          * 
    * - *

    ARMING INVARIANT (eligible-signer binding): blocking should be armed only when the registry - * COVERS the validator set (every current validator has a Falcon key), so that {@code - * eligibleSigners == currentValidators} and the Falcon quorum equals the ECDSA quorum with full - * fault margin. When coverage is incomplete the rule stays LIVE on the intersection (it does not - * halt) but logs a LOUD warning that the margin is reduced and a registry re-anchor is required. - * This is the "either the intersection keeps it live, or it fail-closes at arm time, never as a - * silent halt" contract. + *

    ARMING INVARIANT (AUD-CONSENSUS-1): blocking should be armed only when the registry COVERS the + * validator set (every current validator has a Falcon key), so that {@code eligibleSigners == + * currentValidators} and the Falcon quorum equals the ECDSA quorum with full fault margin. When + * coverage is incomplete the rule stays LIVE on the intersection (it does not halt) but logs a LOUD + * warning that the margin is reduced and a registry re-anchor is required. This is the "either the + * intersection keeps it live, or it fail-closes at arm time, never as a silent halt" contract. * *

    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 @@ -113,9 +127,8 @@ public class FalconSealValidationRule implements AttachedBlockHeaderValidationRu *

    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 2295 lines in twenty minutes, about 165.000 * a day per node, 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 - * that repeats one at that rate is a log an operator stops reading, which is how a real error - * gets missed. + * |eligible|=0, no-eligible-signers}. A line that cannot change carries no information, and seven + * validators had just come out of a disk emergency. * *

    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 @@ -182,14 +195,14 @@ public class FalconSealValidationRule implements AttachedBlockHeaderValidationRu * @return true to log at INFO, false to drop to DEBUG */ boolean shouldLogAtInfo(final String rezumat, final long blockNumber) { - final boolean seSchimba = !rezumat.equals(lastLoggedOutcome); + final boolean hasChanged = !rezumat.equals(lastLoggedOutcome); // Long.MIN_VALUE as "never logged" cannot be subtracted from without overflowing, and an // overflow here would silently invert the comparison: the first block would take the DEBUG // branch and the very first line, the one that tells an operator the rule is alive at all, // would never appear. final boolean bataieDeInima = lastLoggedBlock == Long.MIN_VALUE || blockNumber - lastLoggedBlock >= LOG_HEARTBEAT_BLOCKS; - if (seSchimba || bataieDeInima) { + if (hasChanged || bataieDeInima) { lastLoggedOutcome = rezumat; lastLoggedBlock = blockNumber; 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 // on exactly the input an attacker controls. FAILED now fails CLOSED at and after the fork // block, while PENDING keeps the deliberate log-only behaviour that lets a legitimate anchor - // transaction still land (see the ARMING PRECONDITION note below). + // transaction still land (see AUD-CONSENSUS-4 below). final long forkBlock = pqc.forkBlock(); if (header.getNumber() >= forkBlock && pqc.lateAnchorFailed()) { LOG.error( @@ -246,8 +259,8 @@ public class FalconSealValidationRule implements AttachedBlockHeaderValidationRu pqc.anchorAddress()); return false; } - // ARMING PRECONDITION: entering BLOCKING mode requires BOTH the fork height AND an ACTIVE - // anchored registry (genesis-anchored, or a late anchor already activated by tryActivateLateAnchor + // AUD-CONSENSUS-4: entering BLOCKING mode requires BOTH the fork height AND an ACTIVE anchored + // registry (genesis-anchored, or a late anchor already activated by tryActivateLateAnchor // above). If forkBlock is armed at or before the late-anchor observation height, the registry is // not yet active when the fork block is validated; blocking there rejects every block (empty // registry => no eligible seal) and permanently HALTS the chain before the anchor-deploy @@ -258,12 +271,12 @@ public class FalconSealValidationRule implements AttachedBlockHeaderValidationRu final boolean forkReached = header.getNumber() >= forkBlock; final boolean registryActive = pqc.genesisAnchored() || pqc.lateAnchored(); final boolean blocking = forkReached && registryActive; - // ARMED WITHOUT AN ACTIVE REGISTRY: leave a MARK, not only a line. The log-only answer below is - // the right answer for a header rule, and it is also how this condition used to vanish: the node - // was configured to enforce a post-quantum quorum, enforced nothing, and said so once per block - // into a file. The counter is readable from a test and from a JMX/diagnostic path; the WARN is - // emitted only on the first occurrence, because one line per block at a sub-second block period - // is itself a hazard. + // AERE D-079: leave a MARK, not only a line. The log-only answer below is the right answer for a + // header rule, and it is also how this condition used to vanish: the node was configured to + // enforce a post-quantum quorum, enforced nothing, and said so once per block into a file. The + // counter is readable from a test and from a JMX/diagnostic path; the WARN is emitted only on + // the first occurrence, because one line per block at a sub-second block period is itself a + // hazard on this fleet. if (forkReached && !registryActive && 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. 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 // counted-seal set are derived from this ONE set, so neither can drift from the other. final Set

    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 // validator with a registered key); seals from registered-but-removed validators, or from // unregistered indices, are excluded. - // REGISTRY HEIGHT BINDING (2026-08-06): resolve the key set AT THE HEIGHT OF THE HEADER - // CARRYING THE SEAL, not at this node's head. These seals are over THIS header's committed-seal - // hash, so the height is this header's own - unlike R2, whose certificate commits to the - // PARENT. The adversarial review measured this rule asking a height-less registry; below the - // arming height the resolver still answers from the head registry, so the 11.8 million blocks - // already on chain 2800 are checked exactly as before, but the rule can no longer be the - // reason a rotation makes history unverifiable. + // D2 (2026-08-06): resolve the key set AT THE HEIGHT OF THE HEADER CARRYING THE SEAL, not at + // this node's head. These seals are over THIS header's committed-seal hash, so the height is + // this header's own - unlike R2, whose certificate commits to the PARENT. The adversarial + // review measured this rule asking a height-less registry; below the arming height the + // resolver still answers from the head registry, so the 11.8 million blocks already on chain + // 2800 are checked exactly as before, but the rule can no longer be the reason a rotation + // makes history unverifiable. final Set
    counted = new HashSet<>(); for (final FalconSeal seal : falconSeals) { - // REGISTRY HEIGHT BINDING, the HISTORY door: R1's seals are over THIS header's - // committed-seal hash, so the height is the header's own; the header still came from - // outside. + // D2 (b-v2): the HISTORY door. R1's seals are over THIS header's committed-seal hash, + // so the height is the header's own; the header still came from outside. final Address signer = pqc.addressForIndexAtHistoric(header.getNumber(), seal.getValidatorIndex()); 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={}), " + "but two-fault liveness margin is reduced. A validator was added without an " + "atomic registry re-anchor: RE-ANCHOR the Falcon manifest for the full validator " - + "set, in the same change that adds the validator, never as a later step.", + + "set (see PQ-CONSENSUS-LIVE-READINESS validator-expansion procedure).", header.getNumber(), eligible.size(), validators.size(), @@ -406,15 +418,15 @@ public class FalconSealValidationRule implements AttachedBlockHeaderValidationRu } // Pre-fork: log-only, never blocks. - final String stare = + final String state = eligible.isEmpty() ? "no-eligible-signers" : (valid >= quorum ? "PQC-QUORUM-MET" : "PQC-quorum-not-yet"); // The verdict is already decided above. Everything below only picks a LOG LEVEL. final String rezumat = - stare + "|" + valid + "|" + falconSeals.size() + "|" + eligible.size() + "|" + validators.size(); + state + "|" + valid + "|" + falconSeals.size() + "|" + eligible.size() + "|" + validators.size(); 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|={} " + "(N={}); 2/3 eligible quorum would be {} [{}]. This check never blocks pre-fork; " + "ECDSA committed seals remain decisive."; @@ -422,24 +434,24 @@ public class FalconSealValidationRule implements AttachedBlockHeaderValidationRu lastLoggedOutcome = rezumat; lastLoggedBlock = header.getNumber(); LOG.info( - mesaj, + message, header.getNumber(), valid, falconSeals.size(), eligible.size(), validators.size(), quorum, - stare); + state); } else { LOG.debug( - mesaj, + message, header.getNumber(), valid, falconSeals.size(), eligible.size(), validators.size(), quorum, - stare); + state); } return true; } catch (final Exception e) { diff --git a/anchor/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorDigestAttachedRule.java b/anchor/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorDigestAttachedRule.java index b1a3ba2..896f0f4 100644 --- a/anchor/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorDigestAttachedRule.java +++ b/anchor/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorDigestAttachedRule.java @@ -1,5 +1,5 @@ /* - * Copyright contributors to Besu / Aere Network. + * Copyright contributors to Besu / AERE Network. * * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at diff --git a/anchor/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorDigestRule.java b/anchor/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorDigestRule.java index ce64645..218e83a 100644 --- a/anchor/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorDigestRule.java +++ b/anchor/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorDigestRule.java @@ -1,5 +1,5 @@ /* - * Copyright contributors to Besu / Aere Network. + * Copyright contributors to Besu / AERE Network. * * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at @@ -55,8 +55,8 @@ import org.slf4j.LoggerFactory; * no fast sync here to speak of. [MEASURED: enumerated from the compiled {@code SyncMode}.] *
  • With the anchor armed, {@code PqAnchorSyncModeGuard} REFUSES TO START any node whose mode is * not {@code FULL}. {@code SNAP}, null, blank and unrecognised all abort. Refusing to start is - * not catching anything. [MEASURED: 7 of 7 assertions against the compiled guard, and - * reproduced on a running node.] + * not catching anything. [MEASURED: 7 of 7 assertions against the compiled guard, and on a + * real node in {@code dovezi-sincronizare-2026-08-02/evidence/SNAP-AFTER-armed.txt}.] *
  • With the anchor NOT armed, or disarmed by the emergency option, every mode starts again and * this rule is inert by its own height gate. [MEASURED: both negative controls of the same * probe, which is what shows the probe is measuring the anchor and not the mode string.] @@ -68,10 +68,9 @@ import org.slf4j.LoggerFactory; * applied to closed ones, and {@link PqAnchorDigestAttachedRule}, which carries the identical verdict * on the ATTACHED side so that it survives {@code SKIP_DETACHED} at import. Before those two existed * an honest node with its data directory deleted imported stripped-certificate headers in silence and - * reached head 350 with ZERO occurrences of this rule. [MEASURED on a node restarted from an empty - * data directory; and the NEGATIVE CONTROL, the same run with the two guards stubbed out, - * reproduces exactly that silence, which is what shows the observation is about the guards and not - * about the run.] + * reached head 350 with ZERO occurrences of this rule. [MEASURED: + * {@code dovezi-sincronizare-2026-08-02/evidence/OBS-BEFORE.txt}; and the negative control + * {@code OBS-CONTROL-STUBBED.txt} reproduces exactly that silence once the two guards are stubbed.] * *

    includeInLightValidation, measured. Both {@code DetachedBlockHeaderValidationRule} and * {@code AttachedBlockHeaderValidationRule} declare {@code includeInLightValidation()} with a DEFAULT diff --git a/anchor/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorSealsRule.java b/anchor/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorSealsRule.java index 9be0c7b..21da9d5 100644 --- a/anchor/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorSealsRule.java +++ b/anchor/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorSealsRule.java @@ -1,5 +1,5 @@ /* - * Copyright contributors to Besu / Aere Network. + * Copyright contributors to Besu / AERE Network. * * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at @@ -161,13 +161,13 @@ public class PqAnchorSealsRule implements AttachedBlockHeaderValidationRule { return false; } - // AERE REGISTRY-COVERAGE REPORT (2026-08-02). DIAGNOSTIC ONLY, and deliberately before every - // early return below so it runs at every height from H, including the warm-up where K is 0 - // and the certificate is empty. It feeds FalconSealSupport's coverage report, whose only - // previous source was FalconSealValidationRule - a rule that stands down at exactly the - // height THIS rule takes over. Above H nothing updated it, so the report was either frozen at - // a set from below H or, on a node whose process started above H, never set at all. Nothing - // here can change this rule's verdict: the call cannot throw and its result is not read. + // AERE D-078 (2026-08-02). DIAGNOSTIC ONLY, and deliberately before every early return below + // so it runs at every height from H, including the warm-up where K is 0 and the certificate is + // empty. It feeds FalconSealSupport's registry-coverage report, whose only previous source was + // FalconSealValidationRule - a rule that stands down at exactly the height THIS rule takes + // over. Above H nothing updated it, so the coverage report was either frozen at a set from + // below H or, on a node whose process started above H, never set at all. Nothing here can + // change this rule's verdict: the call cannot throw and its result is not read. observeValidatorsForDiagnostics(parent, protocolContext); final BftExtraData extraData = extraDataCodec.decodeRaw(header.getExtraData()); @@ -247,11 +247,11 @@ public class PqAnchorSealsRule implements AttachedBlockHeaderValidationRule { final Set

    counted = new HashSet<>(); for (final FalconSeal seal : certificate) { final int index = seal.getValidatorIndex(); - // REGISTRY ROTATION: the key set is resolved AT THE PARENT'S HEIGHT, because that is the - // block these seals commit to. Resolving at this block's height would be wrong by one block, - // and at a rotation height "wrong by one block" is a different key set. - // REGISTRY HEIGHT BINDING, the HISTORY door: this rule judges a header this node received, - // at the PARENT's height, which is the height the certificate commits to. It must refuse a + // D-081: the key set is resolved AT THE PARENT'S HEIGHT, because that is the block these + // seals commit to. Resolving at this block's height would be wrong by one block, and at a + // rotation height "wrong by one block" is a different key set and a permanent chain stop. + // D2 (b-v2): the HISTORY door. This rule judges a header this node received, at the + // PARENT's height, which is the height the certificate commits to. It must refuse a // height it has no registry binding for rather than answer from its own head. final Address signer = registry.addressForIndexAtHistoric(parent.getNumber(), index); @@ -360,10 +360,9 @@ public class PqAnchorSealsRule implements AttachedBlockHeaderValidationRule { } /** - * AERE REGISTRY-COVERAGE REPORT: hand the parent's validator set to {@link FalconSealSupport} so - * its coverage report is about the height the chain is actually at. Swallows everything: a rule - * that rejected a header because a diagnostic threw would be a worse defect than the one this - * repairs. + * AERE D-078: hand the parent's validator set to {@link FalconSealSupport} so its registry- + * coverage report is about the height the chain is actually at. Swallows everything: a rule that + * rejected a header because a diagnostic threw would be a worse defect than the one this repairs. * * @param parent the parent header * @param protocolContext the protocol context diff --git a/anchor/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqEmergencyShoutRule.java b/anchor/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqEmergencyShoutRule.java index 0f2ac70..701f7a7 100644 --- a/anchor/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqEmergencyShoutRule.java +++ b/anchor/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqEmergencyShoutRule.java @@ -1,5 +1,5 @@ /* - * Copyright contributors to Besu / Aere Network. + * Copyright contributors to Besu / AERE Network. * * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at diff --git a/anchor/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqRegistryBindingRule.java b/anchor/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqRegistryBindingRule.java index 7792b46..84b7d98 100644 --- a/anchor/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqRegistryBindingRule.java +++ b/anchor/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqRegistryBindingRule.java @@ -1,5 +1,5 @@ /* - * Copyright contributors to Besu / Aere Network. + * Copyright contributors to Besu / AERE Network. * * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at @@ -19,18 +19,17 @@ import org.hyperledger.besu.ethereum.core.BlockHeader; import org.hyperledger.besu.ethereum.mainnet.DetachedBlockHeaderValidationRule; /** - * REGISTRY BINDING, the PER-BLOCK half. + * A8, the PER-BLOCK half of the registry binding. * - *

    The hole this closes, stated as the measurement that found it. The startup guard asks + *

    The hole this closes, stated as the measurement that found it. The A8 startup guard asks * "does the registry on this node match what genesis requires" exactly once, against the chain head * that existed when the process started. {@code config.pqRegistryHash} is a SCHEDULE, so it can have * a second entry: a key rotation, or a validator-set change that re-anchors the manifest. A node * that was already running when such a height passes underneath it is never asked the question * again. It keeps verifying certificates against a registry the chain has moved off, and it keeps - * reporting itself healthy while it does so. That is the same defect the startup guard exists to - * remove, arriving through a different door, and the guard's own honest-limits note said so in - * writing: "the guard runs at startup; it cannot stop a node that is already running when a rotation - * height passes underneath it." + * reporting itself healthy while it does so. That is defect A8 arriving through a different door, + * and the honest limits section of the A8 dossier said so in writing: "the guard runs at startup; it + * cannot stop a node that is already running when a rotation height passes underneath it." * *

    Why a header rule and not a background timer. A timer would have to invent its own * notion of "the current height" and its own reaction, and would be a second, drifting source of @@ -44,9 +43,9 @@ import org.hyperledger.besu.ethereum.mainnet.DetachedBlockHeaderValidationRule; * per height naming the hash required, the height it is required from, the hash found, the file in * use and a fingerprint per registry row. Refusing looks harsh, and the alternative is worse: a node * that imports a header whose certificate it cannot correctly verify has asserted a check it did not - * perform, which is precisely the behaviour the whole registry-binding repair exists to remove. - * Stopping at the first header past the rotation, loudly, is recoverable in one restart; importing - * 200,000 blocks under the wrong registry is not. + * perform, which is precisely the behaviour the whole A8 repair exists to remove. Stopping at the + * first header past the rotation, loudly, is recoverable in one restart; importing 200,000 blocks + * under the wrong registry is not. * *

    Inert unless somebody armed it, three times over. The rule returns true when the startup * guard never ran on this node, when genesis carries no {@code config.pqRegistryHash} at all, and at diff --git a/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/QbftAnchorRuleWiringTest.java b/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/QbftAnchorRuleWiringTest.java index 3a92c29..54ec478 100644 --- a/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/QbftAnchorRuleWiringTest.java +++ b/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/QbftAnchorRuleWiringTest.java @@ -1,8 +1,8 @@ /* - * AERE, the WIRING of the anchor rules into the validation chain. + * AERE D-148, 2026-08-07. The WIRING of the anchor rules into the validation chain. * - * WHY THIS EXISTS, and it is the most expensive lesson of method we have paid for so far: "a gate - * that has never failed cannot be believed". + * WHY THIS EXISTS, and it is the very gap CLAUDE.md names as the most expensive one: "a gate that + * has never failed cannot be believed". * * The three rules are registered in QbftBlockHeaderValidationRulesetFactory. The rules themselves * have good and plentiful tests: 18 in PqAnchorDigestRuleTest, 25 in PqAnchorSealsRuleTest. But ALL @@ -191,4 +191,56 @@ class QbftAnchorRuleWiringTest { + "nothing else matters") .containsAll(ANCHOR_RULES); } + + // --------------------------------------------------------------------------------------------- + // 5. AERE D-AMONTE-02: the base-fee import rule is REALLY in the ruleset the factory builds. + // It is DETACHED, so without the third, lambda-only level of the reader above it would be + // invisible, exactly as PqAnchorDigestRule was on 2026-08-07. Disarmed-by-default: presence + // in the ruleset is precisely what must survive, because the rule switches on by HEIGHT. + // --------------------------------------------------------------------------------------------- + @Test + void theFactoryWiresTheBaseFeeImportRule() throws Exception { + final List inside = + rulesInside( + QbftBlockHeaderValidationRulesetFactory.blockHeaderValidator( + Duration.ofSeconds(1), + false, + Optional.of( + org.hyperledger.besu.ethereum.mainnet.feemarket.FeeMarket.london(0)), + armed()) + .build()); + + assertThat(inside) + .describedAs( + "the base-fee import rule the QBFT factory upstream forgot; if it is missing, somebody " + + "removed the addRule and the one-wei empty-block divergence is back") + .contains("AereBaseFeeImportRule"); + // positive control of the method ON THE SAME CLASS of rule: another detached upstream rule + // must be visible through the same lambda peek, otherwise the assertion above proves nothing + assertThat(inside).contains("AncestryValidationRule"); + } + + // --------------------------------------------------------------------------------------------- + // 6. AERE D-AMONTE-02: arming the property while the chain has NO fee market refuses at + // FACTORY time (node startup), through the real production path, never silently. + // --------------------------------------------------------------------------------------------- + @Test + void armingTheBaseFeeRuleWithoutAFeeMarketRefusesAtFactoryTime() { + try { + System.setProperty( + org.hyperledger.besu.consensus.qbft.headervalidationrules.AereBaseFeeImportRule + .PROPERTY_FORK_BLOCK, + "100"); + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> + QbftBlockHeaderValidationRulesetFactory.blockHeaderValidator( + Duration.ofSeconds(1), false, Optional.empty(), armed())) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("AERE-BASEFEE-VALIDATE-CONF-02"); + } finally { + System.clearProperty( + org.hyperledger.besu.consensus.qbft.headervalidationrules.AereBaseFeeImportRule + .PROPERTY_FORK_BLOCK); + } + } } diff --git a/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/AereBaseFeeImportRuleTest.java b/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/AereBaseFeeImportRuleTest.java new file mode 100644 index 0000000..a37f63f --- /dev/null +++ b/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/AereBaseFeeImportRuleTest.java @@ -0,0 +1,142 @@ +/* AERE D-AMONTE-02, the base-fee import rule's proofs. + * + * The key case REPRODUCES divergence 1bis measured on mixed network 91777: an EMPTY parent at + * the 1 Gwei floor, an EMPTY child claiming floor plus ONE WEI. Nethermind rejected it, Besu + * swallowed it; with the rule armed, Besu rejects it too. The fee market is the REAL + * production one (LondonFeeMarket), with the floor armed through the VERY production path: + * system properties read at fee-market construction, set and cleaned in try/finally (the + * order-dependent-green lesson). */ +package org.hyperledger.besu.consensus.qbft.headervalidationrules; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.hyperledger.besu.datatypes.Wei; +import org.hyperledger.besu.ethereum.core.BlockHeader; +import org.hyperledger.besu.ethereum.core.BlockHeaderTestFixture; +import org.hyperledger.besu.ethereum.mainnet.feemarket.BaseFeeMarket; +import org.hyperledger.besu.ethereum.mainnet.feemarket.FeeMarket; + +import java.util.Optional; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +public class AereBaseFeeImportRuleTest { + + private static final long H_ARMING = 10_000L; + private static final long FLOOR = 1_000_000_000L; // 1 Gwei, chiar valoarea de pe 2800 + private static final long GAZ_LIMITA = 30_000_000L; + + @AfterEach + public void curata() { + System.clearProperty("aere.basefee.floor.forkBlock"); + System.clearProperty("aere.basefee.floor.value"); + System.clearProperty(AereBaseFeeImportRule.PROPERTY_FORK_BLOCK); + } + + /** The REAL fee market, with the AERE floor armed from block 0 through the production path. */ + private BaseFeeMarket feeMarketWithFloor() { + try { + System.setProperty("aere.basefee.floor.forkBlock", "0"); + System.setProperty("aere.basefee.floor.value", String.valueOf(FLOOR)); + return FeeMarket.london(0); + } finally { + System.clearProperty("aere.basefee.floor.forkBlock"); + System.clearProperty("aere.basefee.floor.value"); + } + } + + private BlockHeader header(final long numar, final long taxa, final long gazFolosit) { + return new BlockHeaderTestFixture() + .number(numar) + .baseFeePerGas(Wei.of(taxa)) + .gasLimit(GAZ_LIMITA) + .gasUsed(gazFolosit) + .buildHeader(); + } + + // ------------------------------------------------------------------- dezarmat = amonte + + @Test + public void disarmedAcceptsAnyBaseFeeAnywhere() { + final AereBaseFeeImportRule rule = + new AereBaseFeeImportRule(AereBaseFeeImportRule.DISARMED, Optional.of(feeMarketWithFloor())); + // empty block with a bogus fee: today it passes (the very defect), and disarmed it must pass the same + assertThat(rule.validate(header(H_ARMING, FLOOR + 1, 0), header(H_ARMING - 1, FLOOR, 0))) + .isTrue(); + } + + @Test + public void disarmedNeedsNoFeeMarket() { + // pe un lant pre-London, dezarmat, constructia nu are voie sa cada + assertThat(new AereBaseFeeImportRule(AereBaseFeeImportRule.DISARMED, Optional.empty())) + .isNotNull(); + } + + // ------------------------------------------------------------------- armat, sub si peste H + + @Test + public void belowArmingHeightHistoryIsUntouched() { + final AereBaseFeeImportRule rule = + new AereBaseFeeImportRule(H_ARMING, Optional.of(feeMarketWithFloor())); + // the same header wrong by 1 wei, but BELOW H: history (the floorless days, the + // lost-threshold window) must pass untouched + assertThat(rule.validate(header(H_ARMING - 1, FLOOR + 1, 0), header(H_ARMING - 2, FLOOR, 0))) + .isTrue(); + } + + @Test + public void armedAcceptsTheFlooredEmptyBlock() { + final AereBaseFeeImportRule rule = + new AereBaseFeeImportRule(H_ARMING, Optional.of(feeMarketWithFloor())); + // empty parent at the floor -> EIP-1559 would decrease, the floor holds the fee at 1 Gwei: the live chain itself + assertThat(rule.validate(header(H_ARMING, FLOOR, 0), header(H_ARMING - 1, FLOOR, 0))).isTrue(); + } + + @Test + public void armedRejectsTheOneWeiEmptyBlockDivergence() { + final AereBaseFeeImportRule rule = + new AereBaseFeeImportRule(H_ARMING, Optional.of(feeMarketWithFloor())); + // REPRODUCEREA 1bis: bloc GOL, taxa podea+1. Nethermind o respingea, Besu o inghitea. + assertThat(rule.validate(header(H_ARMING, FLOOR + 1, 0), header(H_ARMING - 1, FLOOR, 0))) + .isFalse(); + } + + @Test + public void armedRejectsABelowFloorEmptyBlock() { + final AereBaseFeeImportRule rule = + new AereBaseFeeImportRule(H_ARMING, Optional.of(feeMarketWithFloor())); + // 875000000 = the very value the unpatched node wrote in the 17 July proof + assertThat(rule.validate(header(H_ARMING, 875_000_000L, 0), header(H_ARMING - 1, FLOOR, 0))) + .isFalse(); + } + + // ------------------------------------------------------------------- refuzurile zgomotoase + + @Test + public void armedWithoutFeeMarketRefusesAtConstruction() { + assertThatThrownBy(() -> new AereBaseFeeImportRule(H_ARMING, Optional.empty())) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("AERE-BASEFEE-VALIDATE-CONF-02"); + } + + @Test + public void brokenPropertyRefusesLoudly() { + try { + System.setProperty(AereBaseFeeImportRule.PROPERTY_FORK_BLOCK, "10,141,734"); + assertThatThrownBy(AereBaseFeeImportRule::armedFromSystemConfig) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("AERE-BASEFEE-VALIDATE-CONF-01"); + } finally { + System.clearProperty(AereBaseFeeImportRule.PROPERTY_FORK_BLOCK); + } + } + + @Test + public void absentPropertyMeansDisarmed() { + System.clearProperty(AereBaseFeeImportRule.PROPERTY_FORK_BLOCK); + assertThat(AereBaseFeeImportRule.armedFromSystemConfig()) + .isEqualTo(AereBaseFeeImportRule.DISARMED); + } +} diff --git a/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/D078GateFeedTest.java b/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/D078GateFeedTest.java new file mode 100644 index 0000000..f016124 --- /dev/null +++ b/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/D078GateFeedTest.java @@ -0,0 +1,189 @@ +/* + * 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.qbft.headervalidationrules; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.CHAIN_ID; +import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.H; +import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.VALIDATORS; +import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.parentHeader; +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.BftContext; +import org.hyperledger.besu.consensus.common.bft.FalconSealSupport; +import org.hyperledger.besu.consensus.common.bft.PqAnchorConfig; +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 java.lang.reflect.Field; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.OptionalInt; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.quality.Strictness; + +/** + * D-078, THE OTHER HALF: who feeds the seal-attachment gate above the anchor height. + * + *

    The gate's registry-coverage report reads a validator set recorded by {@code + * FalconSealSupport.observeValidators}. Until 2026-08-02 the ONLY caller of that method was {@link + * FalconSealValidationRule}, and {@link PqAnchorConfig#legacyFalconRuleRetirementBlock()} stands + * that rule down at exactly the anchor height H. So from H upward nothing fed it: the recorded set + * was either frozen at a height below H, or - on any node whose process started above H - never + * recorded at all. The old gate answered "never recorded" by switching seal attachment off, which + * is one restart away from a chain that cannot propose. + * + *

    This class measures the WIRING, in both directions, and it is the only thing that separates the + * repair from a claim about it: + * + *

      + *
    • the legacy rule really does stand down at H, so it really cannot be the feed above H; + *
    • {@link PqAnchorSealsRule}, which runs at every height from H, really does feed it. + *
    + */ +public class D078GateFeedTest { + + private static final PqAnchorConfig ARMED = + new PqAnchorConfig(CHAIN_ID, H, Map.of(H, 0), OptionalInt.empty(), false); + + @BeforeEach + public void resetSingleton() throws Exception { + forgetFalconSingleton(); + } + + @AfterEach + public void resetSingletonAfter() throws Exception { + forgetFalconSingleton(); + } + + @Test + public void theLegacyRuleStandsDownAtTheAnchorHeightSoItCannotBeTheFeed() { + assertThat(ARMED.legacyFalconRuleRetirementBlock()) + .describedAs("the legacy Falcon rule retires at exactly the anchor height") + .isEqualTo(H); + + final FalconSealValidationRule legacy = + new FalconSealValidationRule(ARMED.legacyFalconRuleRetirementBlock()); + final BlockHeader parent = parentHeader(H - 1L); + final BlockHeader atH = parentHeader(H); + + assertThat(legacy.validate(atH, parent, contextWith(VALIDATORS))) + .describedAs("retired, so it accepts without doing anything") + .isTrue(); + assertThat(FalconSealSupport.instance().observedValidatorsHeight()) + .describedAs( + "at and above the anchor height the legacy rule records NOTHING. That is correct for the " + + "rule and fatal for anything that depended on it as its only source.") + .isEqualTo(-1L); + } + + @Test + public void theAnchorSealsRuleFeedsTheGateAtEveryHeightFromH() { + final PqAnchorSealsRule rule = new PqAnchorSealsRule(ARMED, new NoRegistry()); + final BlockHeader parent = parentHeader(H + 40L); + final BlockHeader block = PqAnchorTestSupport.honestHeader(H + 41L, parent.getHash(), List.of()); + + assertThat(FalconSealSupport.instance().observedValidatorsHeight()).isEqualTo(-1L); + assertThat(rule.validate(block, parent, contextWith(VALIDATORS))) + .describedAs("K is 0 at this height, so an empty certificate is legitimate") + .isTrue(); + assertThat(FalconSealSupport.instance().observedValidatorsHeight()) + .describedAs( + "the rule that takes over at H must also take over feeding the coverage report, or the " + + "report is about a height the chain left behind") + .isEqualTo(parent.getNumber()); + } + + @Test + public void belowTheAnchorHeightTheSealsRuleRecordsNothing() { + // The negative control for the test above: a rule that recorded unconditionally would pass it + // while breaking the height gate that keeps the whole scheme inert below H. + final PqAnchorSealsRule rule = new PqAnchorSealsRule(ARMED, new NoRegistry()); + final BlockHeader parent = parentHeader(H - 3L); + final BlockHeader block = PqAnchorTestSupport.honestHeader(H - 2L, parent.getHash(), List.of()); + + assertThat(rule.validate(block, parent, contextWith(VALIDATORS))).isTrue(); + assertThat(FalconSealSupport.instance().observedValidatorsHeight()) + .describedAs("below H this rule does nothing at all, recording included") + .isEqualTo(-1L); + } + + private static ProtocolContext contextWith(final Collection
    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 forgetFalconSingleton() throws Exception { + final Field f = FalconSealSupport.class.getDeclaredField("instance"); + f.setAccessible(true); + f.set(null, null); + } + + /** A registry that binds nothing: this file measures the feed, never the verification. */ + private static final class NoRegistry + implements org.hyperledger.besu.consensus.common.bft.PqSignerRegistry { + + // D2 (2026-08-06): the height-less pair was deleted from PqSignerRegistry, so this double now + // has to answer "at which height" like everything else. It still binds nothing. + @Override + public Address addressForIndexAtOwnHead(final long blockNumber, final int validatorIndex) { + return addressForIndexAtHistoric(blockNumber, validatorIndex); + } + + @Override + public boolean verifyAtOwnHead( + final long blockNumber, + final int validatorIndex, + final org.apache.tuweni.bytes.Bytes message, + final org.apache.tuweni.bytes.Bytes signature) { + return verifyAtHistoric(blockNumber, validatorIndex, message, signature); + } + + @Override + public Address addressForIndexAtHistoric(final long blockNumber, final int validatorIndex) { + return null; + } + + @Override + public boolean verifyAtHistoric( + final long blockNumber, + final int validatorIndex, + final org.apache.tuweni.bytes.Bytes message, + final org.apache.tuweni.bytes.Bytes signature) { + return false; + } + + @Override + public String toString() { + return "NoRegistry"; + } + } +} diff --git a/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/D079ArmedWithoutRegistryTest.java b/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/D079ArmedWithoutRegistryTest.java new file mode 100644 index 0000000..f2b8dcf --- /dev/null +++ b/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/D079ArmedWithoutRegistryTest.java @@ -0,0 +1,183 @@ +/* + * 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.qbft.headervalidationrules; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.VALIDATORS; +import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.parentHeader; +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.BftContext; +import org.hyperledger.besu.consensus.common.bft.FalconSealSupport; +import org.hyperledger.besu.consensus.common.bft.PqV2Fixture; +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 java.lang.reflect.Field; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collection; + +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-079, THE RESIDUAL: the accident the configuration guard cannot refuse. + * + *

    {@code FalconSealSupport.validateAnchorObservationHeightOrAbort} refuses to start a node whose + * blocking height is armed at or before the height at which its registry can become active. That + * closes the misconfiguration. It cannot close the ACCIDENT: an operator declares the observation + * height correctly, the anchor-deploy transaction does not land, and the blocking height arrives + * over an empty registry anyway. + * + *

    {@link FalconSealValidationRule} answers that by staying LOG-ONLY, and that answer is right - + * blocking over an empty registry buys no safety and costs the chain. It is also exactly how the + * condition used to disappear: the node was configured to enforce a post-quantum quorum, enforced + * nothing, and said so in a warning that nothing reads and no command can exit on. + * + *

    These three tests measure the mark it now leaves, in both directions. + */ +public class D079ArmedWithoutRegistryTest { + + private static final int N = 9; + + private static final long OBSERVE = 5_000L; + + private static final long ATTACH = 6_000L; + + private static final long FORK = 7_000L; + + private static final String ANCHOR_ADDRESS = "0x0000000000000000000000000000000000000fa1"; + + /** AERE D-146: the chain this fixture's registry is BOUND to. Inside every proof, so stated. */ + private static final long CHAIN_ID = 2_800L; + + @TempDir private Path tmp; + + @BeforeEach + public void setUp() throws Exception { + // AERE D-146 (2026-08-06): v2, proof-bound, bound at FORK. This manifest used to spell its + // addresses 0xC00+i; no secp256k1 key produces those, so once AERE-PQC-REG-ARM-02 was wired + // this armed fixture could not start at all. PqV2Fixture lives in consensus:common's test + // source set and reaches here through the testArtifacts dependency this module already had. + final StringBuilder m = new StringBuilder("{"); + m.append(PqV2Fixture.manifestHeader(N, CHAIN_ID, FORK)); + for (int i = 0; i < N; i++) { + m.append(',').append(PqV2Fixture.manifestEntry(i, N, CHAIN_ID, FORK)); + } + m.append("}"); + final Path manifest = tmp.resolve("falcon-late-manifest.json"); + Files.writeString(manifest, m.toString()); + + System.setProperty("aere.falcon.manifest", manifest.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.forkBlock", Long.toString(FORK)); + System.setProperty("aere.falcon.validatorCount", Integer.toString(N)); + forgetFalconSingleton(); + } + + @AfterEach + public void tearDown() throws Exception { + for (final String p : + new String[] { + "aere.falcon.manifest", + "aere.falcon.anchor.address", + "aere.falcon.anchor.block", + "aere.falcon.attachBlock", + "aere.falcon.forkBlock", + "aere.falcon.validatorCount" + }) { + System.clearProperty(p); + } + forgetFalconSingleton(); + } + + @Test + public void belowTheBlockingHeightNothingIsRecorded() { + // The negative control. A counter that were set unconditionally would pass the test below and + // mean nothing at all. + final FalconSealValidationRule rule = new FalconSealValidationRule(Long.MAX_VALUE); + final BlockHeader parent = parentHeader(FORK - 2L); + final BlockHeader block = parentHeader(FORK - 1L); + + assertThat(rule.validate(block, parent, contextWith(VALIDATORS))).isTrue(); + assertThat(FalconSealSupport.instance().blockingArmedWithoutRegistrySince()) + .describedAs("below the blocking height there is nothing inert about being log-only") + .isEqualTo(-1L); + } + + @Test + public void atTheBlockingHeightWithNoActiveRegistryTheHeightIsRecorded() { + final FalconSealValidationRule rule = new FalconSealValidationRule(Long.MAX_VALUE); + final BlockHeader parent = parentHeader(FORK - 1L); + final BlockHeader block = parentHeader(FORK); + + assertThat(FalconSealSupport.instance().blockingArmedWithoutRegistrySince()).isEqualTo(-1L); + assertThat(rule.validate(block, parent, contextWith(VALIDATORS))) + .describedAs( + "the rule must still ACCEPT: blocking over an empty registry is a halt, not a safeguard") + .isTrue(); + assertThat(FalconSealSupport.instance().blockingArmedWithoutRegistrySince()) + .describedAs( + "the node is configured to enforce a Falcon quorum at %d and is enforcing nothing. That " + + "must be a value something can read, not a line in a file.", + FORK) + .isEqualTo(FORK); + assertThat(FalconSealSupport.instance().anchorObserveBlock()) + .describedAs("and the declared height it was measured against must be readable too") + .isEqualTo(OBSERVE); + } + + @Test + public void theRecordedHeightIsTheFirstOneAndDoesNotMoveWithTheChain() { + final FalconSealValidationRule rule = new FalconSealValidationRule(Long.MAX_VALUE); + rule.validate(parentHeader(FORK), parentHeader(FORK - 1L), contextWith(VALIDATORS)); + rule.validate(parentHeader(FORK + 40L), parentHeader(FORK + 39L), contextWith(VALIDATORS)); + + assertThat(FalconSealSupport.instance().blockingArmedWithoutRegistrySince()) + .describedAs( + "the value answers 'since when', so a later block must not overwrite it; if it tracked " + + "the head it would report a fresh problem forever and never a duration") + .isEqualTo(FORK); + } + + private static ProtocolContext contextWith(final Collection

    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 forgetFalconSingleton() throws Exception { + final Field f = FalconSealSupport.class.getDeclaredField("instance"); + f.setAccessible(true); + f.set(null, null); + } +} diff --git a/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/FalconSealLogThrottleTest.java b/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/FalconSealLogThrottleTest.java index f931824..298b439 100644 --- a/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/FalconSealLogThrottleTest.java +++ b/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/FalconSealLogThrottleTest.java @@ -1,5 +1,5 @@ /* - * Copyright contributors to Besu / Aere Network. + * Copyright contributors to Besu / AERE Network. * * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at @@ -27,17 +27,16 @@ import java.util.concurrent.atomic.AtomicInteger; import org.junit.jupiter.api.Test; /** - * AERE: the LOG-ONLY summary must not write one INFO line per block. + * AERE 2026-08-07: the LOG-ONLY summary must not write one INFO line per block. * *

    MEASURED ON A LIVE NODE, and that is why this test exists. The rule wrote the summary at INFO - * on every imported block: 2295 lines in twenty minutes, every one of them identical, {@code - * 0 of 0 seals, |eligible|=0, no-eligible-signers}. On the previous binary the same twenty minutes - * had ZERO such lines, so the spam was entirely introduced by this change. + * on every imported block: 2295 lines in twenty minutes, about 165,000 a day per node, every + * one of them identical, {@code 0 of 0 seals, |eligible|=0, no-eligible-signers}. On the old binary + * the same twenty minutes had ZERO such lines, so the spam was entirely ours. Seven validators had + * just come out of a disk emergency. * - *

    An ERROR or INFO line that repeats forever anaesthetises a log: operators learn to scroll past - * it, and the one line that matters arrives inside the noise. So a binary that replaces a running - * one must add zero new log lines compared with the one it replaces. This test is that requirement, - * for the part a unit test can hold. + *

    The runbook for putting this binary on the seven has, as its own item 3, "zero new log lines + * compared with the old binary". This test is that item, for the part a unit test can hold. */ class FalconSealLogThrottleTest { @@ -45,10 +44,10 @@ class FalconSealLogThrottleTest { private static final String STARE_B = "PQC-quorum-not-yet|3|5|7|7"; private static int numaraInfo( - final FalconSealValidationRule regula, final String stare, final long dePeLa, final int cate) { + final FalconSealValidationRule regula, final String state, final long dePeLa, final int cate) { int n = 0; for (int i = 0; i < cate; i++) { - if (regula.shouldLogAtInfo(stare, dePeLa + i)) { + if (regula.shouldLogAtInfo(state, dePeLa + i)) { n++; } } @@ -56,21 +55,21 @@ class FalconSealLogThrottleTest { } @Test - void primaOaraSeScrieIntotdeauna() { + void theFirstTimeIsAlwaysWritten() { // Without this, an operator starting the node would NEVER see that the rule is alive. final FalconSealValidationRule r = new FalconSealValidationRule(); assertThat(r.shouldLogAtInfo(STARE_A, 12_742_475L)).isTrue(); } @Test - void oMieDeBlocuriCuACEEASIStareScriuOSinguraLinie() { + void aThousandBlocksInTheSAMEStateWriteASingleLine() { final FalconSealValidationRule r = new FalconSealValidationRule(); // 1000 blocks, well below the heartbeat, so only the first one may come out at INFO assertThat(numaraInfo(r, STARE_A, 1_000L, 1_000)).isEqualTo(1); } @Test - void schimbareaStariiSeVEDEPeBloculInCareSeIntampla() { + void aStateChangeIsSEENOnTheBlockWhereItHappens() { final FalconSealValidationRule r = new FalconSealValidationRule(); numaraInfo(r, STARE_A, 1_000L, 500); // exactly the block on which it changes, not the next one and not a heartbeat later @@ -80,7 +79,7 @@ class FalconSealLogThrottleTest { } @Test - void siInapoiLaStareaVecheSeVEDE() { + void andGoingBackToTheOldStateIsSEENToo() { // A regression, that is falling OUT of quorum, matters at least as much as reaching it. final FalconSealValidationRule r = new FalconSealValidationRule(); r.shouldLogAtInfo(STARE_A, 1_000L); @@ -89,7 +88,7 @@ class FalconSealLogThrottleTest { } @Test - void bataiaDeInimaScrieOLinieLaFiecareFereastra() { + void theHeartbeatWritesOneLinePerWindow() { final FalconSealValidationRule r = new FalconSealValidationRule(); final long H = FalconSealValidationRule.LOG_HEARTBEAT_BLOCKS; // 5 whole windows, one single state. Expected: the first line plus one per window. @@ -103,8 +102,8 @@ class FalconSealLogThrottleTest { } @Test - void reducereaEDeCelPutinODieMieDeOri() { - // The number that matters for a node, as a proof rather than a claim in a comment. + void theReductionIsAtLeastAThousandfold() { + // The number that matters for the seven hosts, as a proof rather than a claim in a comment. final FalconSealValidationRule r = new FalconSealValidationRule(); final int blocuriPeZi = 165_000; // 86400 / 0.523 final int info = numaraInfo(r, STARE_A, 1_000L, blocuriPeZi); @@ -113,7 +112,7 @@ class FalconSealLogThrottleTest { } @Test - void nuSeSufocaCandStareaOscileazaLaFiecareBloc() { + void itDoesNotChokeWhenTheStateFlipsEveryBlock() { // The bad case: if the state really does change on every block, the line MUST come out on every // block. A throttle that smothered that too would hide exactly the moment we care about. final FalconSealValidationRule r = new FalconSealValidationRule(); @@ -127,7 +126,7 @@ class FalconSealLogThrottleTest { } @Test - void inaltimiCareVinInNEORDINE_nuAprindBataiaDeInima() { + void heightsArrivingOUTOFORDERDoNotTriggerTheHeartbeat() { // Import runs on several threads, so heights do not always arrive in increasing order. A step // backwards makes the difference negative; that must NOT pass the threshold and write an extra // line. @@ -143,7 +142,7 @@ class FalconSealLogThrottleTest { } @Test - void subMaiMulteFireNuIeseUnPotopSiNiciZero() throws Exception { + void underManyThreadsThereIsNeitherAFloodNorSilence() throws Exception { // The rule is called from EthScheduler-Workers-N, so concurrently. I do not ask for perfect // accuracy under races, but I do ask for the two things that matter: no flood, and no SILENCE. final FalconSealValidationRule r = new FalconSealValidationRule(); diff --git a/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/FalconSealValidationRuleRetirementTest.java b/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/FalconSealValidationRuleRetirementTest.java index 1351cc2..c03a948 100644 --- a/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/FalconSealValidationRuleRetirementTest.java +++ b/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/FalconSealValidationRuleRetirementTest.java @@ -1,5 +1,5 @@ /* - * Copyright contributors to Besu / Aere Network. + * Copyright contributors to Besu / AERE Network. * * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at diff --git a/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorDigestRuleTest.java b/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorDigestRuleTest.java index 7f03e36..64ba7f3 100644 --- a/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorDigestRuleTest.java +++ b/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorDigestRuleTest.java @@ -1,5 +1,5 @@ /* - * Copyright contributors to Besu / Aere Network. + * Copyright contributors to Besu / AERE Network. * * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at diff --git a/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorSealsRuleTest.java b/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorSealsRuleTest.java index 3bf64c2..385abfb 100644 --- a/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorSealsRuleTest.java +++ b/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorSealsRuleTest.java @@ -1,5 +1,5 @@ /* - * Copyright contributors to Besu / Aere Network. + * Copyright contributors to Besu / AERE Network. * * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at @@ -250,18 +250,18 @@ public class PqAnchorSealsRuleTest { } // --------------------------------------------------------------------------------------------- - // KEY ROTATION AND REBINDING: an adversarial review of R2, reproduced against R2 itself. + // D2, the adversarial review of 2026-08-02, reproduced against R2 itself. // - // T2, the first objection: a header that passes both rules today is REJECTED the moment index 0's - // Falcon key is rotated. Nothing else changes - same header, same parent, same validator set. - // T3, the second: if the index is rebound to ANOTHER validator who is still in the current set, - // the rule sees nothing. + // The dossier's T2, in its own words: "a header that passes both rules today is REJECTED the + // moment index 0's Falcon key is rotated. Nothing else changes: same header, same parent, same + // validator set." T3: "if the index is rebound to ANOTHER validator who is still in the current + // set, the rule sees nothing." // - // Neither test could have been written against the earlier interface. PqSignerRegistry carried a + // These two tests could not have been written before 2026-08-06. PqSignerRegistry carried a // height-less pair plus a default that forwarded the height-aware form to it, so every test - // double in the tree - including the review's own probe - silently threw the height away and - // answered from ONE key set. A fake with one key set cannot express a rotation, so it cannot fail - // on one. The compiler now refuses that fake. + // double in the tree - including the D2 harness's own RuleProbe - silently threw the height away + // and answered from ONE key set. A fake with one key set cannot express a rotation, so it cannot + // fail on one. The compiler now refuses that fake. // --------------------------------------------------------------------------------------------- @Test @@ -282,7 +282,7 @@ public class PqAnchorSealsRuleTest { assertThat(rule.validate(block, parent, context)) .describedAs( - "T2: the SAME header, the SAME parent and the SAME validator set, after a key " + "D2/T2: the SAME header, the SAME parent and the SAME validator set, after a key " + "rotation at a height ABOVE it. R2 must resolve keys at the parent's height. If " + "this is false the chain is unjoinable for every node that syncs after a rotation") .isTrue(); @@ -343,7 +343,7 @@ public class PqAnchorSealsRuleTest { assertThat(rule.validate(block, parent, contextWith(VALIDATORS))) .describedAs( - "T3 IS STILL OPEN: R2 accepts a certificate in which index 0's key is credited to " + "D2/T3 IS STILL OPEN: R2 accepts a certificate in which index 0's key is credited to " + "validator 5. When this assertion has to be flipped to isFalse(), T3 has been " + "closed and this comment is the record of when it was not") .isTrue(); @@ -471,9 +471,9 @@ public class PqAnchorSealsRuleTest { } /** - * T2. From {@code from}, index {@code index} answers to a NEW key generation. Everything else - - * the address binding, the validator set, the parent - is untouched, which is the whole point - * of the T2 measurement: a header that verified yesterday must still verify today. + * D2/T2. From {@code from}, index {@code index} answers to a NEW key generation. Everything + * else - the address binding, the validator set, the parent - is untouched, which is the whole + * point of the T2 measurement: a header that verified yesterday must still verify today. */ void rotateKeyFrom(final long from, final int index, final int generation) { final Map carried = new HashMap<>(epochs.floorEntry(from).getValue()); @@ -483,7 +483,7 @@ public class PqAnchorSealsRuleTest { } /** - * T3. From {@code from}, index {@code index} answers to a DIFFERENT validator address, keys + * D2/T3. From {@code from}, index {@code index} answers to a DIFFERENT validator address, keys * untouched. The seal is then credited to a validator that did not sign it. */ void rebindFrom(final long from, final int index, final Address address) { @@ -517,10 +517,10 @@ public class PqAnchorSealsRuleTest { return (rotatedIndex < 0 || validatorIndex == rotatedIndex) ? g : 0; } - // ROTATION HARDENING (a): the height-less pair is gone from PqSignerRegistry, so this double - // can no longer inherit a default that throws the height away. That default is why the original - // probe returned the same verdict on repaired and unrepaired code; a fake that cannot express a - // rotation cannot prove one is handled. + // D2 HARDENING (a), 2026-08-06: the height-less pair is gone from PqSignerRegistry, so this + // double can no longer inherit a default that throws the height away. That default is why the + // original D2 harness (RuleProbe.java:115-131) returned the same verdict on repaired and + // unrepaired code; a fake that cannot express a rotation cannot prove one is handled. @Override public Address addressForIndexAtOwnHead(final long blockNumber, final int validatorIndex) { return addressForIndexAtHistoric(blockNumber, validatorIndex); @@ -557,13 +557,14 @@ public class PqAnchorSealsRuleTest { } // --------------------------------------------------------------------------------------------- - // ROTATION HARDENING (b-v2). WHICH DOOR R2 GOES THROUGH, measured by behaviour, not by name. + // D2 HARDENING (b-v2), 2026-08-06. WHICH DOOR R2 GOES THROUGH, measured by behaviour, not by + // name. // // The shape chosen here admits its own weakness: the compiler forces you to CHOOSE between // addressForIndexAtHistoric and addressForIndexAtOwnHead, but it does not force you to choose // CORRECTLY. Both have the same type and both are total, so a future patch that moves this call - // onto the OWN-HEAD door reopens the rotation defect without anything going red, because the - // OWN-HEAD door is precisely the one that always answers. + // onto the OWN-HEAD door reopens D2 without anything going red, because the OWN-HEAD door is + // precisely the one that always answers. // // The two assertions below are the lock. The registry they run against answers DIFFERENTLY on // the two doors, which no real registry does; that is exactly why it can say which door was @@ -588,7 +589,7 @@ public class PqAnchorSealsRuleTest { "the HISTORY door refuses everything and the OWN-HEAD door answers everything. R2 must " + "REJECT. If this is true, R2 is reading the own-head door, which means it would " + "answer a header it received from the registry in force at this node's head - " - + "and that is the rotation defect verbatim") + + "and that is D2 verbatim") .isFalse(); final PqAnchorSealsRule peUsaCap = diff --git a/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorTestSupport.java b/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorTestSupport.java index 4dafdf0..f7961bc 100644 --- a/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorTestSupport.java +++ b/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorTestSupport.java @@ -1,5 +1,5 @@ /* - * Copyright contributors to Besu / Aere Network. + * Copyright contributors to Besu / AERE Network. * * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at diff --git a/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqArmedWithoutRegistryTest.java b/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqArmedWithoutRegistryTest.java index 419a597..ec110ed 100644 --- a/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqArmedWithoutRegistryTest.java +++ b/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqArmedWithoutRegistryTest.java @@ -1,5 +1,5 @@ /* - * Copyright contributors to Besu / Aere Network. + * Copyright contributors to Besu / AERE Network. * * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at @@ -42,7 +42,7 @@ import org.junit.jupiter.api.io.TempDir; import org.mockito.quality.Strictness; /** - * ARMED WITHOUT A REGISTRY, THE RESIDUAL: the accident the configuration guard cannot refuse. + * D-079, THE RESIDUAL: the accident the configuration guard cannot refuse. * *

    {@code FalconSealSupport.validateAnchorObservationHeightOrAbort} refuses to start a node whose * blocking height is armed at or before the height at which its registry can become active. That @@ -57,6 +57,8 @@ import org.mockito.quality.Strictness; * *

    These three tests measure the mark it now leaves, in both directions. */ +// The D-079 label is our internal finding id. It names a fact about this +// code, not anything outside it. public class PqArmedWithoutRegistryTest { private static final int N = 9; @@ -69,16 +71,14 @@ public class PqArmedWithoutRegistryTest { private static final String ANCHOR_ADDRESS = "0x0000000000000000000000000000000000000fa1"; - /** - * REGISTRY BINDING: the chain this fixture's registry is BOUND to. Inside every proof, so stated. - */ + /** AERE D-146: the chain this fixture's registry is BOUND to. Inside every proof, so stated. */ private static final long CHAIN_ID = 2_800L; @TempDir private Path tmp; @BeforeEach public void setUp() throws Exception { - // REGISTRY BINDING: a v2 manifest, proof-bound, bound at FORK. This manifest used to spell its + // AERE D-146 (2026-08-06): v2, proof-bound, bound at FORK. This manifest used to spell its // addresses 0xC00+i; no secp256k1 key produces those, so once AERE-PQC-REG-ARM-02 was wired // this armed fixture could not start at all. PqV2Fixture lives in consensus:common's test // source set and reaches here through the testArtifacts dependency this module already had. diff --git a/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqEmergencyShoutRuleTest.java b/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqEmergencyShoutRuleTest.java index 4e5d425..17d4dbc 100644 --- a/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqEmergencyShoutRuleTest.java +++ b/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqEmergencyShoutRuleTest.java @@ -1,5 +1,5 @@ /* - * Copyright contributors to Besu / Aere Network. + * Copyright contributors to Besu / AERE Network. * * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at diff --git a/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqForkGateFeedTest.java b/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqForkGateFeedTest.java index d730c7c..3b98133 100644 --- a/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqForkGateFeedTest.java +++ b/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqForkGateFeedTest.java @@ -1,5 +1,5 @@ /* - * Copyright contributors to Besu / Aere Network. + * Copyright contributors to Besu / AERE Network. * * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at @@ -44,7 +44,7 @@ import org.junit.jupiter.api.Test; import org.mockito.quality.Strictness; /** - * SEAL-ATTACHMENT GATE, THE OTHER HALF: who feeds it above the anchor height. + * D-078, THE OTHER HALF: who feeds the seal-attachment gate above the anchor height. * *

    The gate's registry-coverage report reads a validator set recorded by {@code * FalconSealSupport.observeValidators}. Until 2026-08-02 the ONLY caller of that method was {@link @@ -62,6 +62,8 @@ import org.mockito.quality.Strictness; *

  • {@link PqAnchorSealsRule}, which runs at every height from H, really does feed it. * */ +// The D-078 label is our internal finding id. It names a fact about this +// code, not anything outside it. public class PqForkGateFeedTest { private static final PqAnchorConfig ARMED = @@ -151,8 +153,8 @@ public class PqForkGateFeedTest { private static final class NoRegistry implements org.hyperledger.besu.consensus.common.bft.PqSignerRegistry { - // HEIGHT-INDEXED REGISTRY: the height-less pair was deleted from PqSignerRegistry, so this - // double now has to answer "at which height" like everything else. It still binds nothing. + // D2 (2026-08-06): the height-less pair was deleted from PqSignerRegistry, so this double now + // has to answer "at which height" like everything else. It still binds nothing. @Override public Address addressForIndexAtOwnHead(final long blockNumber, final int validatorIndex) { return addressForIndexAtHistoric(blockNumber, validatorIndex);