From a7b24b8f307584b80f20ab73e57c7aef846a544c Mon Sep 17 00:00:00 2001 From: Aere Network Date: Sat, 29 Aug 2026 18:39:31 +0300 Subject: [PATCH] Bring the published anchor overlay level with the tree we run, and say which layers are armed The first publication of this directory, on 11 August 2026, was staged by hand and was never refreshed. Measured on 29 August: of the 78 files published here, only 14 were still identical to the tree we run, 63 had changed, and 50 files had never been published at all. Nobody was hiding anything; there was simply no tool that could redo the selection, so nobody redid it. There is one now, and it derives this directory from the authoritative overlay rather than from anyone's memory. Two things in here contradicted themselves, and both are fixed rather than trimmed. The README withdrew, in one section, a claim that the source next to it still made: that from block 14,050,000 a per-block 2f+1 Falcon quorum was required for a block to finalize. The rule that property arms is retired at the anchor block, so it was inert on arrival. Three places in the code said otherwise and now carry the correction with its date. The old text is kept, not deleted: the rule is real code and can be armed on a chain that never reached an anchor block. What was missing was that on chain 2800 it does not apply. The terminology section said the certificate is signed by f+1 validators and "not by a quorum", and invited the reader to count. Since 14,961,456 the enforced minimum is six of nine, which is a quorum, and the paragraph had not been revised. It now says so, with the date it changed, and adds the two things that make the claim defensible: the word applies only at anchor heights, and the higher threshold costs liveness margin. New in this directory: the post-quantum seal on PREPARE, the hybrid Falcon + SLH-DSA scheme layer, and the test suites for both. Neither is armed on any network, and the README now carries a table saying which layers are armed on chain 2800 and which are only present. They are disarmed by absence rather than by a flag: the properties that arm them are unset, and unset means never. Not published, and it is the same three files every time: the negative-control harnesses. They plant a defect on purpose to prove a guard can fail, which makes them a recipe for disabling a guard rather than a description of one. What they prove is stated in the README. Per-file SHA-256 in MANIFEST-sha256.txt. 1,012 tests, 0 failures, counted from the XML. --- anchor/MANIFEST-sha256.txt | 134 ++++ anchor/README.md | 50 +- .../cli/options/AerePqEmergencyOptions.java | 25 +- .../controller/QbftBesuControllerBuilder.java | 43 +- .../options/AerePqEmergencyOptionsTest.java | 2 +- .../besu/config/JsonGenesisConfigOptions.java | 3 +- .../besu/consensus/common/bft/FalconSeal.java | 2 +- .../common/bft/FalconSealScheme.java | 104 +++ .../common/bft/FalconSealSupport.java | 505 +++++++++----- .../common/bft/HybridSealProducer.java | 140 ++++ .../common/bft/HybridSealSupport.java | 295 +++++++++ .../common/bft/HybridSignerRegistry.java | 286 ++++++++ .../besu/consensus/common/bft/PqAnchor.java | 59 +- .../consensus/common/bft/PqAnchorConfig.java | 74 +-- .../common/bft/PqAnchorNotReadyException.java | 6 +- .../common/bft/PqAnchorSyncModeGuard.java | 6 +- .../common/bft/PqAnchorThresholdGuard.java | 80 ++- .../besu/consensus/common/bft/PqAnchorV2.java | 185 ++++++ .../common/bft/PqRegistryBinding.java | 31 +- .../consensus/common/bft/PqRegistryHash.java | 222 ++++--- .../common/bft/PqRegistryHashTool.java | 83 ++- .../common/bft/PqSchemeSchedule.java | 139 ++++ .../consensus/common/bft/PqSealCache.java | 15 +- .../consensus/common/bft/PqSealStore.java | 17 +- .../common/bft/PqSignerRegistry.java | 33 +- .../besu/consensus/common/bft/SchemeSeal.java | 69 ++ .../besu/consensus/common/bft/SealScheme.java | 96 +++ .../consensus/common/bft/SealSchemes.java | 53 ++ .../common/bft/SlhDsaSealScheme.java | 123 ++++ .../bft/blockcreation/PqAnchorProducer.java | 22 +- .../common/bft/tools/PqRegistryHashTool.java | 26 +- .../bft/D078ThresholdReachabilityTest.java | 354 ++++++++++ .../bft/D078ValidatorSetChangeTest.java | 428 ++++++++++++ .../common/bft/D079ForkArmingTest.java | 368 +++++++++++ .../common/bft/D081RegistryRotationTest.java | 484 ++++++++++++++ .../bft/D140FleetRestartArmingTest.java | 394 +++++++++++ .../common/bft/D141SealPersistenceTest.java | 621 ++++++++++++++++++ .../common/bft/D146ArmingGateTest.java | 387 +++++++++++ .../common/bft/D147InertBinaryTest.java | 466 +++++++++++++ .../common/bft/D177NeutralNamesTest.java | 97 +++ .../common/bft/D2CallerIntentTest.java | 386 +++++++++++ .../bft/D2RegistryHeightRefusalTest.java | 326 +++++++++ .../common/bft/FalconAttachIntervalTest.java | 42 +- .../common/bft/HybridSealProducerTest.java | 177 +++++ .../common/bft/HybridSealSupportTest.java | 206 ++++++ .../common/bft/HybridSignerRegistryTest.java | 225 +++++++ .../common/bft/PqAnchorConfigTest.java | 18 +- .../bft/PqAnchorEmergencyConfigTest.java | 2 +- .../common/bft/PqAnchorIntervalTest.java | 12 +- .../common/bft/PqAnchorMinSealsFloorTest.java | 13 +- .../bft/PqAnchorProducerCacheHygieneTest.java | 99 +++ .../common/bft/PqAnchorProducerCostTest.java | 5 +- .../common/bft/PqAnchorSealCapTest.java | 6 +- .../consensus/common/bft/PqAnchorTest.java | 2 +- .../bft/PqAnchorThresholdGuardTest.java | 73 +- .../consensus/common/bft/PqAnchorV2Test.java | 192 ++++++ .../common/bft/PqArmingGateTest.java | 23 +- .../common/bft/PqCallerIntentTest.java | 34 +- .../common/bft/PqFleetRestartArmingTest.java | 43 +- .../common/bft/PqForkArmingTest.java | 18 +- .../bft/PqForkThresholdReachabilityTest.java | 29 +- .../bft/PqForkValidatorSetChangeTest.java | 35 +- .../common/bft/PqInertBinaryTest.java | 16 +- .../bft/PqParentHeightAlignmentTest.java | 6 +- .../common/bft/PqRegistryBindingTest.java | 12 +- .../bft/PqRegistryHeightRefusalTest.java | 28 +- .../common/bft/PqRegistryRotationTest.java | 58 +- .../common/bft/PqSchemeScheduleTest.java | 127 ++++ .../common/bft/PqSealPersistenceTest.java | 26 +- .../common/bft/PqSignedHeightTest.java | 7 +- .../common/bft/PqStartupHistoryTest.java | 7 +- .../consensus/common/bft/PqV2Fixture.java | 11 +- .../common/bft/PreparePqAttachGateTest.java | 101 +++ .../common/bft/SealSchemeAgilityTest.java | 145 ++++ .../core/network/QbftMessageTransmitter.java | 44 +- .../qbft/core/payload/CommitPayload.java | 121 +++- .../qbft/core/payload/MessageFactory.java | 45 +- .../qbft/core/payload/PreparePayload.java | 204 ++++++ .../core/statemachine/QbftController.java | 372 +++++++++++ .../qbft/core/statemachine/QbftRound.java | 108 ++- .../qbft/core/validation/CommitValidator.java | 180 +++++ .../core/validation/PqCommitEnforcement.java | 332 ++++++++++ .../core/validation/PqPrepareEnforcement.java | 206 ++++++ .../core/validation/PrepareValidator.java | 138 ++++ .../core/payload/CommitPayloadHybridTest.java | 277 ++++++++ .../core/payload/PreparePayloadPqTest.java | 209 ++++++ .../statemachine/PqLateSealSalvageTest.java | 195 ++++++ .../CommitValidatorPqEnforcementTest.java | 152 +++++ .../validation/PqCommitEnforcementTest.java | 159 +++++ .../core/validation/PqCommitPlumbingTest.java | 108 +++ .../validation/PqHybridEnforcementTest.java | 295 +++++++++ .../core/validation/PqPrepareAgilityTest.java | 180 +++++ .../validation/PqPrepareEnforcementTest.java | 266 ++++++++ .../PrepareValidatorPqWiringTest.java | 118 ++++ .../RoundChangeJustificationPqTest.java | 298 +++++++++ ...ftBlockHeaderValidationRulesetFactory.java | 26 +- .../qbft/adaptor/QbftBlockCreatorAdaptor.java | 32 +- .../AereBaseFeeImportRule.java | 135 ++++ .../FalconSealValidationRule.java | 98 +-- .../PqAnchorDigestAttachedRule.java | 2 +- .../PqAnchorDigestRule.java | 13 +- .../PqAnchorSealsRule.java | 33 +- .../PqEmergencyShoutRule.java | 2 +- .../PqRegistryBindingRule.java | 19 +- .../qbft/QbftAnchorRuleWiringTest.java | 58 +- .../AereBaseFeeImportRuleTest.java | 142 ++++ .../D078GateFeedTest.java | 189 ++++++ .../D079ArmedWithoutRegistryTest.java | 183 ++++++ .../FalconSealLogThrottleTest.java | 41 +- ...alconSealValidationRuleRetirementTest.java | 2 +- .../PqAnchorDigestRuleTest.java | 2 +- .../PqAnchorSealsRuleTest.java | 49 +- .../PqAnchorTestSupport.java | 2 +- .../PqArmedWithoutRegistryTest.java | 12 +- .../PqEmergencyShoutRuleTest.java | 2 +- .../PqForkGateFeedTest.java | 10 +- 116 files changed, 13159 insertions(+), 937 deletions(-) create mode 100644 anchor/MANIFEST-sha256.txt create mode 100644 anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/FalconSealScheme.java create mode 100644 anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/HybridSealProducer.java create mode 100644 anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/HybridSealSupport.java create mode 100644 anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/HybridSignerRegistry.java create mode 100644 anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorV2.java create mode 100644 anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqSchemeSchedule.java create mode 100644 anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/SchemeSeal.java create mode 100644 anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/SealScheme.java create mode 100644 anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/SealSchemes.java create mode 100644 anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/SlhDsaSealScheme.java create mode 100644 anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D078ThresholdReachabilityTest.java create mode 100644 anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D078ValidatorSetChangeTest.java create mode 100644 anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D079ForkArmingTest.java create mode 100644 anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D081RegistryRotationTest.java create mode 100644 anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D140FleetRestartArmingTest.java create mode 100644 anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D141SealPersistenceTest.java create mode 100644 anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D146ArmingGateTest.java create mode 100644 anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D147InertBinaryTest.java create mode 100644 anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D177NeutralNamesTest.java create mode 100644 anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D2CallerIntentTest.java create mode 100644 anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D2RegistryHeightRefusalTest.java create mode 100644 anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/HybridSealProducerTest.java create mode 100644 anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/HybridSealSupportTest.java create mode 100644 anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/HybridSignerRegistryTest.java create mode 100644 anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorProducerCacheHygieneTest.java create mode 100644 anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorV2Test.java create mode 100644 anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqSchemeScheduleTest.java create mode 100644 anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PreparePqAttachGateTest.java create mode 100644 anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/SealSchemeAgilityTest.java create mode 100644 anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/PreparePayload.java create mode 100644 anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/QbftController.java create mode 100644 anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/CommitValidator.java create mode 100644 anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/PqCommitEnforcement.java create mode 100644 anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/PqPrepareEnforcement.java create mode 100644 anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/PrepareValidator.java create mode 100644 anchor/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/payload/CommitPayloadHybridTest.java create mode 100644 anchor/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/payload/PreparePayloadPqTest.java create mode 100644 anchor/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/statemachine/PqLateSealSalvageTest.java create mode 100644 anchor/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/CommitValidatorPqEnforcementTest.java create mode 100644 anchor/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqCommitEnforcementTest.java create mode 100644 anchor/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqCommitPlumbingTest.java create mode 100644 anchor/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqHybridEnforcementTest.java create mode 100644 anchor/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqPrepareAgilityTest.java create mode 100644 anchor/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqPrepareEnforcementTest.java create mode 100644 anchor/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PrepareValidatorPqWiringTest.java create mode 100644 anchor/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/RoundChangeJustificationPqTest.java create mode 100644 anchor/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/AereBaseFeeImportRule.java create mode 100644 anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/AereBaseFeeImportRuleTest.java create mode 100644 anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/D078GateFeedTest.java create mode 100644 anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/D079ArmedWithoutRegistryTest.java 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);