From 099908a4ca3190679e748efcbb95a0f77a8db649 Mon Sep 17 00:00:00 2001 From: Aere Network Date: Wed, 23 Sep 2026 20:08:22 +0300 Subject: [PATCH] anchor: the corpus carries the AIP-22 stage 1 to 3b code (per-scheme intervals, post-quantum validator identity, v3 key registry, key rotation under an id), unarmed on chain 2800 and proven on testnet 28001; the armed-on-2800 table says so row by row; LICENSING.md lists the new files --- LICENSING.md | 4 +- anchor/MANIFEST-sha256.txt | 40 +- anchor/README.md | 6 +- .../controller/QbftBesuControllerBuilder.java | 55 + .../consensus/common/bft/AerePqIdentity.java | 561 ++++++++++ .../common/bft/BftBlockInterface.java | 2 +- .../common/bft/FalconSealSupport.java | 206 +++- .../common/bft/HybridSealProducer.java | 109 +- .../common/bft/HybridSealSupport.java | 90 +- .../consensus/common/bft/PqRegistryV3.java | 963 ++++++++++++++++++ .../common/bft/PqSchemeSchedule.java | 134 ++- .../blockcreation/BftBlockCreatorFactory.java | 9 +- .../bft/blockcreation/PqAnchorProducer.java | 18 +- .../common/bft/tools/PqRegistryV3Tool.java | 288 ++++++ .../common/bft/AereKeysFromV3Test.java | 192 ++++ .../common/bft/AereLocalKeysV3Test.java | 164 +++ .../common/bft/AerePqIdentityTest.java | 135 +++ .../common/bft/HybridSealProducerTest.java | 29 + .../common/bft/PqRegistryV3Test.java | 509 +++++++++ .../common/bft/PqSchemeScheduleTest.java | 52 + .../blockbased/VoteTallyIdentityTest.java | 75 ++ .../qbft/core/validation/CommitValidator.java | 5 +- .../core/validation/PqCommitEnforcement.java | 34 +- .../validation/AereIdentityMessageTest.java | 165 +++ .../validation/PqHybridEnforcementTest.java | 39 + .../PqAnchorSealsRule.java | 30 +- .../qbft/AereIdentityHeaderTest.java | 144 +++ .../PqAnchorV2RulesTest.java | 34 + 28 files changed, 4034 insertions(+), 58 deletions(-) create mode 100644 anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/AerePqIdentity.java create mode 100644 anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqRegistryV3.java create mode 100644 anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/tools/PqRegistryV3Tool.java create mode 100644 anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/AereKeysFromV3Test.java create mode 100644 anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/AereLocalKeysV3Test.java create mode 100644 anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/AerePqIdentityTest.java create mode 100644 anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqRegistryV3Test.java create mode 100644 anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/validator/blockbased/VoteTallyIdentityTest.java create mode 100644 anchor/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/AereIdentityMessageTest.java create mode 100644 anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/AereIdentityHeaderTest.java diff --git a/LICENSING.md b/LICENSING.md index 914b8ad..cf7ef5f 100644 --- a/LICENSING.md +++ b/LICENSING.md @@ -132,9 +132,9 @@ repository, and the publisher refuses to ship a package whose list disagrees wit You do not need that tool to check the list: every row below names a file in this package, and `grep -i copyright` on it shows the same line. -Measured 2026-09-23 over `anchor/`: **179 files**. **90** carry someone +Measured 2026-09-23 over `anchor/`: **189 files**. **90** carry someone else's copyright alone, **45** carry a shared notice naming both upstream and us, and **11** name only -`contributors to the AERE Network`; 33 carry no copyright line at all. +`contributors to the AERE Network`; 43 carry no copyright line at all. The first two groups are the table below. A shared notice is listed too: those are precisely the upstream files we changed, so section 4(b) has something to say about each of them. Every file below is Apache-2.0 material redistributed under section 4: it keeps its original diff --git a/anchor/MANIFEST-sha256.txt b/anchor/MANIFEST-sha256.txt index 2dfe97c..5cfa14f 100644 --- a/anchor/MANIFEST-sha256.txt +++ b/anchor/MANIFEST-sha256.txt @@ -5,19 +5,20 @@ computed them, and the same 276 files once produced two different aggregates her 329e4e2f93143cc8af092f0a951fa23619c12c4ec1793b053654f8e26e088da0 app/src/main/java/org/hyperledger/besu/cli/BesuCommand.java 3f289d74bb1878280a99742320d7a27c19146a2f19cdf6a0857f2aad4361ad5e app/src/main/java/org/hyperledger/besu/cli/options/AerePqEmergencyOptions.java -e17f5b548f5a0833aef11148f561f2854dacf501bbe7c98f926349707ef77429 app/src/main/java/org/hyperledger/besu/controller/QbftBesuControllerBuilder.java +6a324fd14b4dea445ac08a7ef43bedb800c64c11c4bcbaefb58f98ec9051e60a 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 68d635954ef33448a661256f50642eeeff2f7bb5874a54ffc2e64193c83fe391 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/AereAnchorProposalDelay.java -8fa317b1ba1d887f0623610f8721d728d954cc800d3b391729ed06f64c8141a6 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/BftBlockInterface.java +348135b2e4d0a6a8ce91489c94fbfa77da2536196c13823676b33ea9d08f8fb9 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/AerePqIdentity.java +c28e3bf26d68ab7b764f6381bc678d5f7b60bfa7d02796e3ac34521c5c5bc6ed consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/BftBlockInterface.java 2e84bb48611d3da7c46bcd4018ae746cbf785dfcb16f495674a0f6d542e71e8e consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/BftExtraData.java 70fa067469a084ac225765da6a01619dec51130acc255b7194a1ad274d327141 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/BlockTimer.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 -789db234a90f118cdf881653ee2bb7ad4a229a685315b04822147ffffe6a3c13 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 -be99cd76f4b80a0c83e0a18801249c7972a9e93c54bbad6158d1155753a7ad6a consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/HybridSealSupport.java +dc4c68ad18d64aedae2363fc83f7a5329d258e749b9f7cbe145ba8496139785e consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/FalconSealSupport.java +570f7825afdb0dd10e1d920f7de8ae21a85f196e77edbd8da8de94994d845839 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/HybridSealProducer.java +dbb4381155c4222491676da2e6cd13a8b15ce38c59a14ee8cb65edd3af1bf282 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/HybridSealSupport.java 681d5ca2ea929d81f92664341d2651160d50621efb5194808627fb61834c292b consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/HybridSignerRegistry.java ecd0e865aa48e461618048c13c46d9b54095dfa2e14180750f49b90faf49302a consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchor.java 1c3d90324c6f5116194bae0539680fdc1f528ba4c893c5e2b44a592aea6b3abd consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorConfig.java @@ -29,7 +30,8 @@ e16b2c654fe21893c7972dac71f3ffafd1e4e8efbecdb6f95051820e36fbd48e consensus/comm 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 +11f1c2a97a40df999471977cb3eab9af4e816081b100e345e8c962ef3bbf76f5 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqRegistryV3.java +2ee139d21f73ff3417f6ad61b233e5ca3a95f92e68a4284eb99a47b1f8f4b58b consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqSchemeSchedule.java c0c93a096f4eceab6bf92cc3618ce7cee1e54d2d20b6ac781850a390ec6e514f consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqSealCache.java 73abbaddcec1533a7a758f1c4a26ae68a83ddbb96fd98ad147e74d7386bbbf3f 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 @@ -37,8 +39,8 @@ addba40c0d931a3ecfa3b2f0179311dacaa604884e3ebb1d79f958bd913f72e2 consensus/comm 285f6a7c1188a387fb1ac21f5ab809595ef48d936b40aced94047525e6def0a6 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/SealScheme.java 3b57840dc103063b3797697feb78b90af93ca123587e9278cfb43b84edbd995a consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/SealSchemes.java 444961503d515cb9f045729577e190941e59baa4bd90438a2da05f41fed7750a 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 -15ee94346cd803530cf9362b74c645aad0986c6d26a5386f9f38ee4e92a66b7b consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/blockcreation/PqAnchorProducer.java +454eeca487505e70c7f0000f5401b6bf4530da1d5ce6bcb803427f1d08e3cb1c consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/blockcreation/BftBlockCreatorFactory.java +e89deb6f346c221affa717eafa464d497401b7b5a01c84c144dace42032879a9 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/blockcreation/PqAnchorProducer.java baa7f138046c501c5baecae3615d54ed0bbd6fea47d7f84a3d71eaa5698d182d consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/ADRS.java 7c464b927fcb39ab017c543ca42bcd076b1dc69573c7ddefda1e685ebf9eb9ba consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/Fors.java ddb17d7c6e1b762a9e8bcc924a17d42218cb5ec16528d80170bf2ff07e40aba2 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/HT.java @@ -61,10 +63,14 @@ c72f197a0f8967aec5e08d1c4bb30030b9dc16c27711567ca40ad0cbe845c989 consensus/comm 636399666ed9490b29a4cb85d2b3cddf2ef4e8f8fa9a8ecf2315a8abf3e2e6de consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/SLHDSASigner.java bd6833075646ffa2f4fca2761e3d68ccbc37f7c5bb209ae2e40f8cfd469dce14 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/WotsPlus.java 6ace00e18914a1558563e689b7427654b5e6226a31447bef1d0416d993f78840 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/tools/PqRegistryHashTool.java +ed1cc6f617dc5a2f7801b6e8f6f039a035a2ffc9542b116262dac39bb70563dd consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/tools/PqRegistryV3Tool.java cf0dcc008ce2d040590329088abdcbfc0829bf529445422deee8022d4856e08a consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/AereAnchorProposalDelayTest.java +f5d6d26b9cd3babad8f7d16b9595497908b1f854d166837fbecd485d721ae856 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/AereKeysFromV3Test.java +1d7e3a30d3f4653b778a1bcbb81203ed8855d904017fa438a06e26baad8958c3 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/AereLocalKeysV3Test.java +15d2d2cfc627406ae6183353a81703fd40da871b216ff8e9b1c4d15af2437805 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/AerePqIdentityTest.java acd5c1e7f49dbbf444c19c91386bbd03b9298d952d2d13721793670103e71ee9 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D177NeutralNamesTest.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 +e96c6e79287a17eafef8f4ff5c32e539e489115b02f7afaceb8cdeff73562d69 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 2920f18dae0eb7ccead43d80202580b116e28c9fae5bb75d54917ddb99b9c2e9 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorConfigTest.java @@ -90,7 +96,8 @@ f260d284f936307ac4c31142cbea0359ded1197880b8a960ac27dd7beb5c3b53 consensus/comm 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 +05423fc69ce0e4e94cda0d56a181a996320bd2166af3415f2d88102cf3b0ae21 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqRegistryV3Test.java +763dcb09c30c2031ac19bb62ec7a8a16a2f8042fd0e3d04840867b08df5b1ba3 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqSchemeScheduleTest.java e73a075b2acfce7a6d2de63130ed55a3d8f422bdc72c51325a010852064d406d 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 @@ -101,6 +108,7 @@ e355b147fdda48c14cb6dc09b1bc1e7a7ccae3f9b709af8f8483f896086dbe4d consensus/comm d48d62c6ea0784864a5987a804a28855265b2180c409ba3c5b4434d21b805e1d consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/SealSchemeAgilityTest.java 184e3cc6f2f9beb1bd972b077ab2ced456264a55296f810ba5ed2dc976195392 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/SlhDsaCrossVectorTest.java ae37d3525697bf84defd796bb8813193c78f81e7fa24a72f8f048285d52399e2 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/SlhDsaFastEngineTest.java +bcdd5c3c267f38eee9700bc6ba07d41b487c7889224820868e1839044108451f consensus/common/src/test/java/org/hyperledger/besu/consensus/common/validator/blockbased/VoteTallyIdentityTest.java 6b6ecfcfb9b25bb009cb5d4df74648c729abf62626e5034af126771825b7418e consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/messagewrappers/Commit.java 597d96d64c9394c79d5084a1ab5df884b2266efe315c0f2d563bfc17b7421480 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 @@ -113,9 +121,9 @@ efb0e336e19a16ef192e3d1f91f1e5488f632e17b5748654e75008ddfcdda932 consensus/qbft 9c7e2698016f8cf19d3b6d78c6256ad5e8eabec06d9a877eee4e8c2dde423839 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/QbftRound.java 00c8204e4b44acd469973d6bbc07992382fdd2d692f079a55de76f944a7432f8 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 -01ed4a44aa58da124efb6f4fcf479f9511c8f522f40ccf0be5fff3d0bcb38f34 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/CommitValidator.java +4c0811d182f5252bdde31560f514a518db58c8d0c82c748a6c0f5d68472470c6 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/CommitValidator.java a40621537e2b2024ab5586c9001f96fd81ffe3c3d5ce1e867a07a77584cf2173 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/MessageValidator.java -bebf53cbc7f3a9ca2807bd980d313f8131e96f79259ba41e80a598d0327b1a0d consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/PqCommitEnforcement.java +22c73f9cdfe5626347f34f173b7b60312e00970ccba49862686051b2dd84fe36 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 742611d52091a6c138cdff60c5f7f0bfb2fc33e567f520f3e86878fc086e77d9 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/PqProposalEnforcement.java afb487f8c1dd87e323fdc1153ecfdf8ec6cb5a10e0726783d4214c07a3fa9a95 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/PqRoundChangeEnforcement.java @@ -131,12 +139,13 @@ df5d7d2119cf0864a8c2dce50f4f69bd8b64804cfda7f7366c013b304af0a6e7 consensus/qbft 97ac1e46ab37238a2b6232eccdd4636ac6fa928c8e3d4f8a473c083c5d5c3b39 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/statemachine/PqHybridExtrasNeverThrowTest.java 0bb2c1e31a7f29e9b07fb7030567746fc6d79460ed4739d5ecafac44501f7bb5 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/statemachine/PqLateSealSalvageTest.java 127de5fbe8014768a657f31bc77d7e66557273febdf3412a5536e8628fd70c5c consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/statemachine/PqSealsWithoutProposalTest.java +11c968ba3eb4adabb548af5a0f6192b21ec75ff01431cc678fdfc6c13f0d4937 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/AereIdentityMessageTest.java 3f945a0a73288e42c82026ead5d118ab6b7a8200aacc881855aff442d0954c1c consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/CommitValidatorAnchorFormTest.java 8c40173c7b248edec7c29ee9e049f9e269f4725b637bceb8734735fc73eb9ca2 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/CommitValidatorPqEnforcementTest.java 3374a7ee317a807528c79b8218dbb34b308de8b7a908cb6680b83356641fcade consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/MessageValidatorAnchorFormPlumbingTest.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 -61f6f0ca66cabbad7e57d7eccf86a6d4f5434f0769726db24efec38c9503583d consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqHybridEnforcementTest.java +52e75fd98b9d2ab3d6f87fc8ea1a638321e70d5eec5f14db615ee13755ba1287 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqHybridEnforcementTest.java bca46ca91803c89eb18d670c740edb4e47ef0bca53a9b4b82ce0c8446e0d6c0e consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqMessageAgilityTest.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 @@ -155,9 +164,10 @@ cbec6f4e280250ddefe5c79c453b59a07be6435e6f88d5614e92168c855e4693 consensus/qbft 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 63313f944fcda0db45c40d4df840a465e6147cc85997b8ca832f0cfefea97987 consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorDigestRule.java -bfea357d0f539ae7d2d6fabf2ea343e0bce3d3faf2cea57c6d5e97c75552371b consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorSealsRule.java +0f348ac4fa519f27f52f631dd290eab6e84980739a09ec767c8ca69e4a4f8f64 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 +5dcf5c22389c063e12363b83366b39bd3b2e79f36c68e777fb3ef11080f53ea9 consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/AereIdentityHeaderTest.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 ad018cba0a3fe7f018b11c6c6a2d45e3f5547342ec45b620a4df2595801ae71a consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/FalconSealLogThrottleTest.java @@ -166,7 +176,7 @@ e2df575ee4d6ab5bd961b0886ece3d3c392a50193f1d3256a438a72da9e20d10 consensus/qbft 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 -612d5c7578a918441265a6ad99bf05e9c5e94eec743c375ffab03d1b19e2f797 consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorV2RulesTest.java +2a76e2500e7d26fc2443f4a37211d59eb1e80e736430dca0785dd2f3f6ea1daf consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorV2RulesTest.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 diff --git a/anchor/README.md b/anchor/README.md index 47e4ea7..5b8f129 100644 --- a/anchor/README.md +++ b/anchor/README.md @@ -176,6 +176,9 @@ This matters more than the file list, so it is stated before it. | Post-quantum seal on COMMIT, emission / enforcement | yes | **yes**, since the anchor / **armed for block 17,250,000** | | Hybrid Falcon + SLH-DSA certificate (v2) | yes | **yes**, since block 17,047,600 (2026-09-04) | | Anchor interval 128 (`aere.pq.anchorIntervalSchedule`) | yes | **armed for block 17,225,968** (set 2026-09-05) | +| Per-scheme anchor intervals, Falcon-512 every 32 and SLH-DSA every 128 (AIP-22 stage 2, `scheme/interval` steps in `aere.pq.schemeSchedule`) | yes (added 2026-09-23) | **no**; proven on testnet 28001 only | +| Post-quantum validator identity and the v3 key registry (AIP-22 stage 3, `aere.pq.identityBlock`, `aere.pq.registryV3`) | yes (added 2026-09-23) | **no**; proven on testnet 28001 only | +| Sealing keys read from the v3 epochs, key rotation under an id (AIP-22 stage 3b, `aere.pq.registryV3.keysFrom`, `aere.falcon.keys.next`) | yes (added 2026-09-23) | **no**; proven on testnet 28001 only | Everything not yet in force is **disarmed by absence or by height, never 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 @@ -197,7 +200,8 @@ proposal without a valid Falcon seal from its own proposer does not open a round its own domain over (chainId, height, round, digest), so an honest proposal seal cannot be replayed as a vote nor a vote seal as a proposal - both directions are tested. The ROUND-CHANGE message carries its own seal since 2026-08-31 (its embedded justifications are coupled to PREPARE -enforcement). What remains classical: node-level devp2p authentication and validator identity. +enforcement). What remains classical on chain 2800: node-level devp2p authentication and validator identity (the +post-quantum identity of AIP-22 is in this directory, unarmed on chain 2800, and runs only on testnet 28001). ### The files 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 281add4..e85efbf 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 @@ -30,6 +30,7 @@ import org.hyperledger.besu.config.QbftFork; import org.hyperledger.besu.consensus.common.BftValidatorOverrides; import org.hyperledger.besu.consensus.common.EpochManager; import org.hyperledger.besu.consensus.common.ForksSchedule; +import org.hyperledger.besu.consensus.common.bft.AerePqIdentity; import org.hyperledger.besu.consensus.common.bft.BftBlockInterface; import org.hyperledger.besu.consensus.common.bft.BftContext; import org.hyperledger.besu.consensus.common.bft.BftEventQueue; @@ -433,6 +434,36 @@ public class QbftBesuControllerBuilder extends BesuControllerBuilder { // about what is actually configured on this node. final PqAnchorConfig aereAnchorConfig = PqAnchorConfig.fromSystemConfiguration(); + // AERE AIP-22 stage 3: the post-quantum identity switch, read ONCE here beside the anchor + // configuration; the schemes every v3 row must carry are the ones the scheme schedule names at + // H_id. Inert when aere.pq.identityBlock is unset. + AerePqIdentity.installFromSystemProperties( + aereAnchorConfig.chainId(), + hId -> + HybridSealSupport.instance() + .schedule() + .map(s -> (java.util.List) new java.util.ArrayList<>(s.schemesAt(hId))) + .orElse(java.util.List.of("falcon-512"))); + // AERE AIP-22 stage 3b: at H_k the SOURCE of the keys that seal moves to the v3 epochs; the continuity + // guard proves, before the node joins anything, that no key moves with it (every key of the epoch at + // H_k equals the key the existing registries give at H_k - 1). Inert when H_k is not configured. + AerePqIdentity.verifyKeyContinuity( + (index, scheme) -> { + final long before = + AerePqIdentity.installed() + .map(c -> c.keysFrom() - 1L) + .orElseThrow(() -> new IllegalStateException("identity not installed")); + return "falcon-512".equals(scheme) + ? FalconSealSupport.instance().publicKeyBytesAtHistoric(before, index) + : HybridSealSupport.instance().registry().flatMap(r -> r.publicKey(index, scheme)); + }); + // AERE AIP-22 stage 3b-ii: in every v3 epoch from H_k where this node is a registered validator it must + // hold its row's keys, or it would go mute at that epoch; refuse to start instead. Inert without H_k. + FalconSealSupport.instance() + .verifyLocalKeysCoverV3EpochsOrAbort(Util.publicKeyToAddress(nodeKey.getPublicKey())); + HybridSealSupport.instance() + .verifyLocalKeysCoverV3EpochsOrAbort(Util.publicKeyToAddress(nodeKey.getPublicKey())); + PqAnchorSyncModeGuard.verifySyncModeOrAbort( syncConfig == null || syncConfig.getSyncMode() == null ? null @@ -487,6 +518,30 @@ public class QbftBesuControllerBuilder extends BesuControllerBuilder { + "node starts with an empty cache, exactly as before this repair.", e.toString()); } + // AIP-21, SECOND HALF (2026-09-18): the finality WINDOW survives the restart too. Same + // security argument as above, one step further: a journal frame counts only if its hash is + // the canonical hash THIS chain holds at that height, and each seal only if it verifies now + // against the registry epoch bound there. A forged journal buys an empty window. + try { + final BlockHeader aereCapFereastra = blockchain.getChainHeadHeader(); + final int aereFereastraRestaurata = + PqSealCache.instance() + .restoreWindowFromDisk( + aereCapFereastra.getNumber(), + aereInaltime -> + blockchain.getBlockHeader(aereInaltime).map(BlockHeader::getHash), + PqSignerRegistry.falconSealSupport()); + LOG.info( + "AERE FEREASTRA-SIGILII (AIP-21): {} VERIFIED seal(s) restored into the finality window " + + "below chain head {}; aere_getPqFinality answers for those blocks again.", + aereFereastraRestaurata, + aereCapFereastra.getNumber()); + } catch (final Exception e) { + LOG.warn( + "AERE FEREASTRA-SIGILII (AIP-21): the finality window could not be restored ({}); the " + + "node answers 'unavailable' for blocks heard before this restart, as before.", + e.toString()); + } } final BftExecutors bftExecutors = diff --git a/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/AerePqIdentity.java b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/AerePqIdentity.java new file mode 100644 index 0000000..99778b6 --- /dev/null +++ b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/AerePqIdentity.java @@ -0,0 +1,561 @@ +/* + * AERE AIP-22 stage 3: the post-quantum validator identity, in ONE place. + * + * WHAT IT DOES. From the activation height H_id, a validator is its 20-byte id (PqRegistryV3), not its + * ECDSA address. Upstream QBFT works entirely on two 20-byte things - the AUTHOR of a signed message and + * the validator LIST - so the switch is a TRANSLATION applied at the few places where an ECDSA address + * is born, and nowhere else (the choke points are listed in the AIP-22 plan, section 9.4): + * + * author(recovered, h) the author of a message or commit seal at height h: the id of the v3 row + * whose ecdsa.addr is the recovered address when h >= H_id, the address itself + * otherwise (an address with no row is then simply not in the set, and its + * message does not count - the upstream checks already say so) + * translateSet(set, b) the validator set after block b = H_id - 1 becomes the sorted ids of its + * members (VoteTallyUpdater), so extraData(H_id) carries ids and H_id is the + * first block sealed by ids; a member without a v3 row is a configuration + * FAULT that throws, never a silent drop + * local(address, h) this node's own identity at height h (coinbase, proposer check) + * isLocal(candidate, a) whether a 20-byte value is this node, in either form + * transportTargets(ids) the RLPx node-key addresses to send consensus messages to, from the rows' + * transport field; empty when some id lists none (send to every peer then) + * + * DISARMED unless installed: with no configuration every function is the identity, so a build that + * carries this class and is never configured behaves exactly as before, byte for byte. + * + * Configuration (system properties, like every AERE rule, read once at startup): + * aere.pq.identityBlock H_id + * aere.pq.registryV3 comma list of v3 manifests (JSON), each bound at its own bindHeight + * aere.pq.registryV3.hashes comma list "bindHeight:0xhash" - the pinned hash of every epoch; an + * epoch without a pin refuses startup (a registry nobody pinned is a file) + * + * NOT here (stated): the PQ seal registry lookups (index -> address) move to ids through the v3 epoch + * entering the registry schedule; the per-message PQ seal authorship check (SPEC 2.6 rule 2) then + * compares id with id. + */ +package org.hyperledger.besu.consensus.common.bft; + +import org.hyperledger.besu.datatypes.Address; + +import java.io.IOException; +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.NavigableMap; +import java.util.Optional; +import java.util.TreeMap; +import java.util.TreeSet; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.base.Splitter; +import org.apache.tuweni.bytes.Bytes; +import org.apache.tuweni.bytes.Bytes32; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** The AIP-22 identity translation. */ +public final class AerePqIdentity { + + private static final Logger LOG = LoggerFactory.getLogger(AerePqIdentity.class); + + /** H_id. */ + public static final String PROPERTY_BLOCK = "aere.pq.identityBlock"; + + /** The v3 manifests. */ + public static final String PROPERTY_REGISTRIES = "aere.pq.registryV3"; + + /** The pinned hashes of the v3 manifests. */ + public static final String PROPERTY_HASHES = "aere.pq.registryV3.hashes"; + + /** + * AIP-22 stage 3b: H_k, the height from which the keys that SEAL come from the v3 epoch in force + * (instead of the Falcon registry schedule and the hybrid registry). Must be the bindHeight of a v3 + * epoch and at or above H_id. Unset: the keys stay where they are (stage 3a). + */ + public static final String PROPERTY_KEYS_FROM = "aere.pq.registryV3.keysFrom"; + + /** No H_k: the v3 epochs never supply keys. */ + public static final long NEVER = Long.MAX_VALUE; + + /** + * An installed configuration. + * + * @param identityBlock H_id + * @param epochs v3 epochs by bindHeight + * @param keysFrom H_k, or {@link #NEVER} + */ + public record Config( + long identityBlock, NavigableMap epochs, long keysFrom) { + + /** + * A stage-3a configuration: identity only, keys stay in the existing registries. + * + * @param identityBlock H_id + * @param epochs v3 epochs by bindHeight + */ + public Config(final long identityBlock, final NavigableMap epochs) { + this(identityBlock, epochs, NEVER); + } + } + + /** A configuration fault of the identity switch. */ + public static final class IdentityException extends RuntimeException { + private static final long serialVersionUID = 1L; + + /** + * A fault. + * + * @param message what is wrong + */ + public IdentityException(final String message) { + super("AERE-PQC-ID: " + message); + } + } + + private static volatile Config config; + + private AerePqIdentity() {} + + /** + * Installs a configuration (startup, or a test). Checks that H_id has an epoch bound at or below it. + * + * @param c the configuration, or null to disarm + */ + public static void install(final Config c) { + if (c != null && c.epochs().floorEntry(c.identityBlock()) == null) { + throw new IdentityException( + "no v3 registry epoch is bound at or below the identity height " + c.identityBlock()); + } + if (c != null && c.keysFrom() != NEVER) { + // H_k switches where the keys come FROM; it must fall on an epoch boundary at or after H_id, so + // the epoch that starts supplying keys is a whole epoch, verified and pinned like every other + if (c.keysFrom() < c.identityBlock() || !c.epochs().containsKey(c.keysFrom())) { + throw new IdentityException( + PROPERTY_KEYS_FROM + + "=" + + c.keysFrom() + + " must be the bindHeight of a v3 epoch at or above the identity height " + + c.identityBlock() + + " (epochs " + + c.epochs().keySet() + + ")"); + } + } + config = c; + if (c != null) { + LOG.info( + "AERE PQ IDENTITY: ARMED - validators are post-quantum ids from height {}; v3 epochs {}{}", + c.identityBlock(), + c.epochs().keySet(), + c.keysFrom() == NEVER + ? "" + : "; the keys that seal come from the v3 epoch in force from height " + c.keysFrom()); + } + } + + /** Disarms (tests). */ + public static void uninstall() { + config = null; + } + + /** + * The installed configuration. + * + * @return it, if armed + */ + public static Optional installed() { + return Optional.ofNullable(config); + } + + /** + * Reads the configuration from the system properties and installs it. No property: stays disarmed. + * + * @param chainId the chain + * @param requiredAt the schemes every row must carry, as the scheme schedule names them at a height + */ + public static void installFromSystemProperties( + final long chainId, final java.util.function.LongFunction> requiredAt) { + final String block = System.getProperty(PROPERTY_BLOCK); + if (block == null || block.isBlank()) { + return; + } + final long h = Long.parseLong(block.trim()); + final List requiredSchemes = requiredAt.apply(h); + final String files = System.getProperty(PROPERTY_REGISTRIES, ""); + final String hashes = System.getProperty(PROPERTY_HASHES, ""); + final TreeMap pins = new TreeMap<>(); + for (final String p : Splitter.on(',').omitEmptyStrings().trimResults().split(hashes)) { + final int i = p.indexOf(':'); + if (i < 0) { + throw new IdentityException(PROPERTY_HASHES + " entry '" + p + "' is not bindHeight:0xhash"); + } + pins.put(Long.parseLong(p.substring(0, i)), Bytes32.fromHexString(p.substring(i + 1))); + } + final ObjectMapper m = new ObjectMapper(); + final List loaded = new ArrayList<>(); + PqRegistryV3.Registry previous = null; + final List paths = new ArrayList<>(); + for (final String f : Splitter.on(',').omitEmptyStrings().trimResults().split(files)) { + paths.add(Path.of(f)); + } + // epochs are loaded in bindHeight order, each against the one before it (rotations) + final TreeMap byHeight = new TreeMap<>(); + for (final Path p : paths) { + try { + final com.fasterxml.jackson.databind.JsonNode j = m.readTree(Files.readString(p)); + byHeight.put(j.path("bindHeight").asLong(-1), j); + } catch (final IOException e) { + throw new IdentityException("cannot read v3 manifest " + p + ": " + e.getMessage()); + } + } + for (final var e : byHeight.entrySet()) { + final Bytes32 pin = pins.get(e.getKey()); + if (pin == null) { + throw new IdentityException( + "the v3 epoch bound at " + e.getKey() + " has no pinned hash in " + PROPERTY_HASHES); + } + final boolean switchEpoch = previous == null; + final PqRegistryV3.Registry r = + PqRegistryV3.loadPinned(e.getValue(), chainId, requiredSchemes, previous, switchEpoch, pin); + loaded.add(r); + previous = r; + } + final TreeMap epochs = new TreeMap<>(); + for (final PqRegistryV3.Registry r : loaded) { + epochs.put(r.bindHeight(), r); + } + final String keysFrom = System.getProperty(PROPERTY_KEYS_FROM); + install( + new Config( + h, + Collections.unmodifiableNavigableMap(epochs), + keysFrom == null || keysFrom.isBlank() ? NEVER : Long.parseLong(keysFrom.trim()))); + } + + // --------------------------------------------------------------------------------------------- + // AIP-22 stage 3b: the keys that seal, from the v3 epoch in force + + /** + * Whether the keys that seal at this height come from the v3 epoch in force (at or above H_k). + * Every key lookup asks this first; below H_k (and disarmed) it answers false and the caller uses + * its existing registry, untouched. + * + * @param height the height the key is asked for, in the caller's own convention (the parent of an + * anchor for a certificate seal) + * @return true from H_k when armed + */ + public static boolean keysFromV3(final long height) { + final Config c = config; + return c != null && height >= c.keysFrom(); + } + + /** + * The public key of a registry index for a scheme at a height, from the v3 epoch in force, in the + * form the existing registries carry it: Falcon-512 WITHOUT the 0x09 header byte (the 896-byte h + * that {@code FalconPublicKeyParameters} takes), SLH-DSA-SHA2-128s as its 32 bytes. Empty when the + * epoch has no such row or key: the caller then has NO key (fail closed), it never falls back to + * the old registries, because above H_k they are no longer the key set the chain requires. + * + * @param height the height (see {@link #keysFromV3}) + * @param index the registry index carried by the seal + * @param schemeId e.g. "falcon-512" + * @return the key, or empty + */ + public static Optional v3KeyAt(final long height, final int index, final String schemeId) { + return epochAt(height) + .filter(r -> index >= 0 && index < r.rows().size()) + .map(r -> r.rows().get(index)) + .flatMap(row -> row.key(schemeId)) + .map( + k -> + "falcon-512".equals(schemeId) + ? java.util.Arrays.copyOfRange(k.publicKey(), 1, k.publicKey().length) + : k.publicKey().clone()); + } + + /** + * AIP-22 stage 3b-ii: the index of the row of the v3 epoch in force that carries a given public key + * for a scheme (the key in the registry form of {@link #v3KeyAt}). How a node finds, per epoch, + * which row is its own: indices move between epochs, so a key file's index cannot say it. + * + * @param height the height + * @param schemeId e.g. "falcon-512" + * @param publicKey the key, registry form + * @return the row index, or empty when no row of that epoch carries the key + */ + public static Optional v3IndexOfKey( + final long height, final String schemeId, final byte[] publicKey) { + final Optional r = epochAt(height); + if (r.isEmpty() || publicKey == null) { + return Optional.empty(); + } + for (final PqRegistryV3.Row row : r.get().rows()) { + final Optional k = v3KeyAt(height, row.index(), schemeId); + if (k.isPresent() && java.util.Arrays.equals(k.get(), publicKey)) { + return Optional.of(row.index()); + } + } + return Optional.empty(); + } + + /** + * The id of a registry index at a height, from the v3 epoch in force (stage 3b: the index names + * the v3 row directly, from H_k). + * + * @param height the height + * @param index the registry index + * @return the 20-byte id as an address, or empty when the epoch has no such row + */ + public static Optional
v3IdAt(final long height, final int index) { + return epochAt(height) + .filter(r -> index >= 0 && index < r.rows().size()) + .map(r -> Address.wrap(Bytes.wrap(r.rows().get(index).id()))); + } + + /** + * THE CONTINUITY GUARD, run once at startup when H_k is configured: switching the SOURCE of the + * keys at H_k must not change any key. Every key of every row of the epoch in force at H_k must be + * exactly the key the existing registries give that index just below H_k; only a LATER epoch may + * rotate, and only through the rotation signatures of the old keys, which the v3 loader verifies. + * A mismatch, or a key the existing registries cannot produce, refuses startup (fail closed): a + * node that swapped keys at H_k would accept seals no other node accepts. + * + * @param keysBefore (index, schemeId) to the key the existing registries give at H_k - 1, in the + * registry form (see {@link #v3KeyAt}) + */ + public static void verifyKeyContinuity( + final java.util.function.BiFunction> keysBefore) { + final Config c = config; + if (c == null || c.keysFrom() == NEVER) { + return; + } + final PqRegistryV3.Registry r = c.epochs().get(c.keysFrom()); + int checked = 0; + for (final PqRegistryV3.Row row : r.rows()) { + for (final PqRegistryV3.Key k : row.keys().values()) { + final String scheme = k.scheme().id(); + final byte[] v3 = v3KeyAt(c.keysFrom(), row.index(), scheme).orElseThrow(); + final Optional before = keysBefore.apply(row.index(), scheme); + if (before.isEmpty() || !java.util.Arrays.equals(before.get(), v3)) { + throw new IdentityException( + "continuity at " + + PROPERTY_KEYS_FROM + + "=" + + c.keysFrom() + + ": row " + + row.index() + + " " + + scheme + + (before.isEmpty() + ? " has no key in the existing registries at " + (c.keysFrom() - 1) + : " differs from the key the existing registries give at " + (c.keysFrom() - 1)) + + "; switching the key source must not change a key (a rotation belongs in a later" + + " epoch, signed by the old keys)"); + } + checked++; + } + } + LOG.info( + "AERE PQ IDENTITY: key continuity at {} verified, {} keys of {} rows equal the existing" + + " registries at {}", + c.keysFrom(), + checked, + r.rows().size(), + c.keysFrom() - 1); + } + + // --------------------------------------------------------------------------------------------- + + /** + * Whether ids are the identity at this height. + * + * @param height the height + * @return true from H_id when armed + */ + public static boolean active(final long height) { + final Config c = config; + return c != null && height >= c.identityBlock(); + } + + /** + * The v3 epoch in force at a height. + * + * @param height the height + * @return the epoch, if armed and one is bound at or below it + */ + public static Optional epochAt(final long height) { + final Config c = config; + if (c == null) { + return Optional.empty(); + } + final var e = c.epochs().floorEntry(height); + return e == null ? Optional.empty() : Optional.of(e.getValue()); + } + + /** + * The author of a message or commit seal whose ECDSA signature recovered to this address. + * + * @param recovered the recovered address + * @param height the height the message or seal is for + * @return the id from H_id (when a row links the address), the address otherwise + */ + public static Address author(final Address recovered, final long height) { + if (!active(height)) { + return recovered; + } + return idOfEcdsa(recovered.getBytes().toArrayUnsafe(), height) + .map(i -> Address.wrap(Bytes.wrap(i))) + .orElse(recovered); + } + + /** + * The id an ECDSA address links to at a height. The ecdsa link is required only in the switch epoch + * (AIP-22 3.2) and a later epoch may omit it, while messages still travel with an ECDSA signature + * until stage 4; so the link is INHERITED by id: the most recent epoch at or below the height that + * links this address names the id, and that id must still be registered in the epoch in force (a + * removed id's old link means nothing). A new id that never carried a link has no ECDSA author, so + * its classical messages cannot count before stage 4: stated in the plan (9.5), not hidden. + */ + private static Optional idOfEcdsa(final byte[] address, final long height) { + final Config c = config; + if (c == null) { + return Optional.empty(); + } + final var head = c.epochs().floorEntry(height); + if (head == null) { + return Optional.empty(); + } + // ids that a NEWER epoch already links to some address: an older link of another address to such an + // id is superseded (the id moved its ECDSA key), so that older address no longer authors as the id + final java.util.Set relinked = new java.util.HashSet<>(); + for (final PqRegistryV3.Registry e : c.epochs().headMap(height, true).descendingMap().values()) { + final Optional linked = e.rowOfEcdsa(address); + if (linked.isPresent()) { + final byte[] i = linked.get().id(); + return !relinked.contains(Bytes.wrap(i)) && head.getValue().rowOf(i).isPresent() + ? Optional.of(i) + : Optional.empty(); + } + for (final PqRegistryV3.Row r : e.rows()) { + if (r.ecdsaAddress() != null) { + relinked.add(Bytes.wrap(r.id())); + } + } + } + return Optional.empty(); + } + + /** + * This node's identity at a height. + * + * @param localEcdsa the node's ECDSA address + * @param height the height + * @return the id from H_id when the node has a row, the address otherwise + */ + public static Address local(final Address localEcdsa, final long height) { + return author(localEcdsa, height); + } + + /** + * Whether a 20-byte value is this node, as an address or as its id in any epoch. + * + * @param candidate the value + * @param localEcdsa the node's ECDSA address + * @return whether it is this node + */ + public static boolean isLocal(final Address candidate, final Address localEcdsa) { + if (candidate.equals(localEcdsa)) { + return true; + } + final Config c = config; + if (c == null) { + return false; + } + for (final PqRegistryV3.Registry r : c.epochs().values()) { + final Optional row = r.rowOfEcdsa(localEcdsa.getBytes().toArrayUnsafe()); + if (row.isPresent() && candidate.equals(Address.wrap(Bytes.wrap(row.get().id())))) { + return true; + } + } + return false; + } + + /** + * Whether the set after this block changes form (armed, and the block is H_id - 1). Callers ask + * first so that, disarmed or at any other block, the upstream code path is untouched - not even an + * extra call on the tally (upstream VoteTallyUpdaterTest asserts exactly that). + * + * @param blockNumber the block + * @return true only at H_id - 1 when armed + */ + public static boolean translatesAfter(final long blockNumber) { + final Config c = config; + return c != null && blockNumber == c.identityBlock() - 1L; + } + + /** + * The validator set after block {@code blockNumber}, translated when that block is H_id - 1. + * + * @param set the set after the block, as upstream computed it + * @param blockNumber the block + * @return the same set, or its sorted ids at H_id - 1 + */ + public static Collection
translateSetAfter( + final Collection
set, final long blockNumber) { + final Config c = config; + if (c == null || blockNumber != c.identityBlock() - 1L) { + return set; + } + final PqRegistryV3.Registry r = + epochAt(c.identityBlock()) + .orElseThrow(() -> new IdentityException("no v3 epoch at " + c.identityBlock())); + final TreeSet
ids = new TreeSet<>(); + for (final Address a : set) { + final Optional row = r.rowOfEcdsa(a.getBytes().toArrayUnsafe()); + if (row.isEmpty()) { + throw new IdentityException( + "validator " + + a + + " is in the set after block " + + blockNumber + + " and has no row in the v3 epoch bound at " + + r.bindHeight() + + "; the identity switch refuses rather than dropping a validator"); + } + ids.add(Address.wrap(Bytes.wrap(row.get().id()))); + } + LOG.info( + "AERE PQ IDENTITY: the set after block {} is translated to {} post-quantum ids", + blockNumber, + ids.size()); + return ids; + } + + /** + * The RLPx node-key addresses consensus messages for these recipients go to. + * + * @param recipients validator identities (ids or addresses) + * @return the transport addresses; EMPTY when some id lists no transport, meaning "every peer" + */ + public static Optional> transportTargets(final Collection
recipients) { + final Config c = config; + if (c == null) { + return Optional.of(recipients); + } + final PqRegistryV3.Registry head = c.epochs().lastEntry().getValue(); + final List
out = new ArrayList<>(); + for (final Address a : recipients) { + final Optional row = head.rowOf(a.getBytes().toArrayUnsafe()); + if (row.isEmpty()) { + out.add(a); // a classical address (below H_id), sent to as before + } else if (row.get().transport() == null) { + return Optional.empty(); + } else { + out.add(Address.wrap(Bytes.wrap(row.get().transport()))); + } + } + return Optional.of(out); + } +} diff --git a/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/BftBlockInterface.java b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/BftBlockInterface.java index 77af53d..daf840b 100644 --- a/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/BftBlockInterface.java +++ b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/BftBlockInterface.java @@ -156,7 +156,7 @@ public class BftBlockInterface implements BlockInterface { bftExtraDataCodec)); return bftExtraData.getSeals().stream() - .map(p -> Util.signatureToAddress(p, committerHash)) + .map(p -> AerePqIdentity.author(Util.signatureToAddress(p, committerHash), header.getNumber())) .collect(Collectors.toList()); } } 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 7049b84..8482c3d 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 @@ -151,6 +151,27 @@ public final class FalconSealSupport { private final boolean signingEnabled; private final int localIndex; private final FalconPrivateKeyParameters localPrivateKey; + + /** + * AERE AIP-22 stage 3b-ii: this node's NEXT Falcon keys ({@code aere.falcon.keys.next} / + * {@code AERE_FALCON_KEYS_NEXT}, comma list of key files in the same format as {@code + * aere.falcon.key}), for the v3 epochs that rotate its key. From H_k the key and the index this node + * signs with at a height are the ones of the v3 row that carries one of its keys (see {@link + * #localAt}); the file's "index" property then means nothing, because indices move between epochs. + */ + private final java.util.List nextPrivateKeys; + + /** The local key and index resolved per v3 epoch (bindHeight), from H_k. */ + private final Map> localByEpoch = new ConcurrentHashMap<>(); + + /** + * A local signing key with the registry index it signs as. + * + * @param key the private key + * @param index the registry index at the height it was resolved for + */ + private record LocalKey(FalconPrivateKeyParameters key, int index) {} + private final Map registry; /** @@ -590,6 +611,36 @@ public final class FalconSealSupport { } } + // AERE AIP-22 stage 3b-ii: the next keys, same file format; a file that does not load refuses + // startup, because a validator that silently lacks its rotated key goes mute at the rotation epoch + final java.util.List next = new java.util.ArrayList<>(); + final String nextPaths = resolve("aere.falcon.keys.next", "AERE_FALCON_KEYS_NEXT"); + if (nextPaths != null) { + for (final String np : com.google.common.base.Splitter.on(',').omitEmptyStrings().trimResults().split(nextPaths)) { + if (np.isBlank()) { + continue; + } + try (InputStream in = new FileInputStream(np.trim())) { + final Properties p = new Properties(); + p.load(in); + next.add( + new FalconPrivateKeyParameters( + FalconParameters.falcon_512, + Bytes.fromHexStringLenient(p.getProperty("f").trim()).toArray(), + Bytes.fromHexStringLenient(p.getProperty("g").trim()).toArray(), + Bytes.fromHexStringLenient(p.getProperty("F").trim()).toArray(), + Bytes.fromHexStringLenient(p.getProperty("pk").trim()).toArray())); + LOG.info("AERE PQC: loaded a NEXT Falcon signing key from {} (AIP-22 stage 3b-ii)", np.trim()); + } catch (final Exception e) { + throw new IllegalStateException( + "AERE-PQC-KEY-NEXT-01: cannot load the next Falcon key from '" + + np.trim() + + "': " + + e.getMessage()); + } + } + } + this.nextPrivateKeys = java.util.List.copyOf(next); this.registry = reg; this.indexToAddress = regAddr; this.localIndex = idx; @@ -2524,6 +2575,89 @@ public final class FalconSealSupport { return signingEnabled; } + /** + * AERE AIP-22 stage 3b-ii. The key this node signs with at a height, and the index it signs as. + * Below H_k (and disarmed): the key of aere.falcon.key and its file index, exactly as before. From + * H_k: the v3 row of the epoch in force whose Falcon key is the public key of one of this node's + * keys (the base key or a next key); indices move between epochs, so the index is that row's. No + * row carries any of this node's keys: null, so nothing is signed (the startup guard {@link + * #verifyLocalKeysCoverV3EpochsOrAbort} refuses a validator that would end up there). + */ + private LocalKey localAt(final long height) { + if (!AerePqIdentity.keysFromV3(height)) { + return localPrivateKey == null ? null : new LocalKey(localPrivateKey, localIndex); + } + final long bind = + AerePqIdentity.epochAt(height).map(PqRegistryV3.Registry::bindHeight).orElse(-1L); + return localByEpoch + .computeIfAbsent( + bind, + b -> { + final java.util.List mine = new java.util.ArrayList<>(); + if (localPrivateKey != null) { + mine.add(localPrivateKey); + } + mine.addAll(nextPrivateKeys); + for (final FalconPrivateKeyParameters k : mine) { + final java.util.Optional i = + AerePqIdentity.v3IndexOfKey(height, "falcon-512", k.getPublicKey()); + if (i.isPresent()) { + return java.util.Optional.of(new LocalKey(k, i.get())); + } + } + return java.util.Optional.empty(); + }) + .orElse(null); + } + + /** + * AERE AIP-22 stage 3b-ii. The registry index this node signs as at a height (the index of every + * other scheme's seal of this node too: an SLH-DSA seal must sit at the index of its Falcon seal). + * + * @param height the height + * @return the index, or -1 when this node holds no key of that height + */ + public int localIndexAt(final long height) { + final LocalKey lk = localAt(height); + return lk == null ? -1 : lk.index(); + } + + /** + * AERE AIP-22 stage 3b-ii, THE STARTUP GUARD of the local keys: in every v3 epoch from H_k in which + * this node's identity is registered, this node must hold the Falcon key of its row, otherwise it + * refuses to start. A validator whose rotated key is missing would sign nothing from the rotation + * epoch on: mute, while every gate that asks "is it configured?" stays green (D-307). + * + * @param thisNodeAddress this node's own validator (ECDSA) address + */ + public void verifyLocalKeysCoverV3EpochsOrAbort(final Address thisNodeAddress) { + final java.util.Optional c = AerePqIdentity.installed(); + if (c.isEmpty() || c.get().keysFrom() == AerePqIdentity.NEVER || thisNodeAddress == null) { + return; + } + for (final PqRegistryV3.Registry e : c.get().epochs().tailMap(c.get().keysFrom(), true).values()) { + final Address me = AerePqIdentity.author(thisNodeAddress, e.bindHeight()); + if (e.rowOf(me.getBytes().toArrayUnsafe()).isEmpty()) { + continue; // this node is not a registered validator in this epoch: nothing to sign + } + if (localAt(e.bindHeight()) == null) { + throw new IllegalStateException( + "AERE-PQC-KEY-NEXT-02: REFUSING TO START. This node (" + + thisNodeAddress + + ", id " + + me + + ") is registered in the v3 epoch bound at " + + e.bindHeight() + + " but holds no Falcon key of its row there (aere.falcon.key / aere.falcon.keys.next);" + + " from that height it would sign nothing."); + } + LOG.info( + "AERE PQC: local Falcon key covers the v3 epoch bound at {} (index {}).", + e.bindHeight(), + localAt(e.bindHeight()).index()); + } + } + /** * This node's validator index in the Falcon registry. * @@ -3460,13 +3594,17 @@ public final class FalconSealSupport { // 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 LocalKey lk = localAt(blockNumber); // AERE AIP-22 stage 3b-ii + if (lk == null) { + return Optional.empty(); + } final java.util.Optional sig = - ((FalconSealScheme) SealSchemes.FALCON_512).signWithParams(localPrivateKey, commitHash.toArray()); + ((FalconSealScheme) SealSchemes.FALCON_512).signWithParams(lk.key(), 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()))); + return Optional.of(new FalconSeal(lk.index(), Bytes.wrap(sig.get()))); } /** @@ -3548,8 +3686,12 @@ public final class FalconSealSupport { if (blockNumber < prepareAttachBlock()) { return Optional.empty(); } + final LocalKey lk = localAt(blockNumber); // AERE AIP-22 stage 3b-ii + if (lk == null) { + return Optional.empty(); + } final java.util.Optional sig = - ((FalconSealScheme) SealSchemes.FALCON_512).signWithParams(localPrivateKey, message.toArray()); + ((FalconSealScheme) SealSchemes.FALCON_512).signWithParams(lk.key(), message.toArray()); if (sig.isEmpty()) { LOG.warn("AERE PQ PREPARE: Falcon signing failed (ECDSA path unaffected)"); return Optional.empty(); @@ -3570,7 +3712,7 @@ public final class FalconSealSupport { } 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()))); + return Optional.of(new FalconSeal(lk.index(), Bytes.wrap(sig.get()))); } /** @@ -3653,8 +3795,12 @@ public final class FalconSealSupport { if (blockNumber < proposalAttachBlock()) { return Optional.empty(); } + final LocalKey lk = localAt(blockNumber); // AERE AIP-22 stage 3b-ii + if (lk == null) { + return Optional.empty(); + } final java.util.Optional sig = - ((FalconSealScheme) SealSchemes.FALCON_512).signWithParams(localPrivateKey, message.toArray()); + ((FalconSealScheme) SealSchemes.FALCON_512).signWithParams(lk.key(), message.toArray()); if (sig.isEmpty()) { LOG.warn("AERE PQ PROPOSAL: Falcon signing failed (ECDSA path unaffected)"); return Optional.empty(); @@ -3672,7 +3818,7 @@ public final class FalconSealSupport { } else if (n % 500L == 0L) { LOG.info("AERE PQ PROPOSAL: {} seals emitted on PROPOSALs since startup.", n); } - return Optional.of(new FalconSeal(localIndex, Bytes.wrap(sig.get()))); + return Optional.of(new FalconSeal(lk.index(), Bytes.wrap(sig.get()))); } /** @@ -3752,8 +3898,12 @@ public final class FalconSealSupport { if (blockNumber < roundChangeAttachBlock()) { return Optional.empty(); } + final LocalKey lk = localAt(blockNumber); // AERE AIP-22 stage 3b-ii + if (lk == null) { + return Optional.empty(); + } final java.util.Optional sig = - ((FalconSealScheme) SealSchemes.FALCON_512).signWithParams(localPrivateKey, message.toArray()); + ((FalconSealScheme) SealSchemes.FALCON_512).signWithParams(lk.key(), message.toArray()); if (sig.isEmpty()) { LOG.warn("AERE PQ ROUNDCHANGE: Falcon signing failed (ECDSA path unaffected)"); return Optional.empty(); @@ -3771,7 +3921,7 @@ public final class FalconSealSupport { } else if (n % 100L == 0L) { LOG.info("AERE PQ ROUNDCHANGE: {} seals emitted on ROUND-CHANGEs since startup.", n); } - return Optional.of(new FalconSeal(localIndex, Bytes.wrap(sig.get()))); + return Optional.of(new FalconSeal(lk.index(), Bytes.wrap(sig.get()))); } /** @@ -3813,6 +3963,15 @@ public final class FalconSealSupport { */ private FalconPublicKeyParameters keyAt( final long blockNumber, final int validatorIndex, final boolean historic) { + // AERE AIP-22 stage 3b: from H_k the key of an index is the one of the v3 epoch in force at this + // SAME height (the caller's convention is kept: the parent of an anchor for its certificate). No + // row or no key there means no key: never a fall-back to the registries below, which above H_k + // are no longer the key set the chain requires. Below H_k (and disarmed) nothing changes. + if (AerePqIdentity.keysFromV3(blockNumber)) { + return AerePqIdentity.v3KeyAt(blockNumber, validatorIndex, "falcon-512") + .map(pk -> new FalconPublicKeyParameters(FalconParameters.falcon_512, pk)) + .orElse(null); + } final PqRegistryHash.Schedule schedule = this.registryBindingSchedule; final PqRegistryHash.RegistrySet set = this.registryBindingSet; if (schedule == null || set == null) { @@ -3868,6 +4027,20 @@ public final class FalconSealSupport { return null; } + /** + * AERE AIP-22 stage 3b. The Falcon-512 public key (the 896-byte h, as the registries carry it) that + * the chain's registries give an index at a height, through the same history door as a received + * header. Used by the continuity guard at H_k - 1, where the v3 epochs do not yet supply keys. + * + * @param blockNumber the height + * @param validatorIndex the registry index + * @return the key, or empty when the registries give none there + */ + public Optional publicKeyBytesAtHistoric(final long blockNumber, final int validatorIndex) { + final FalconPublicKeyParameters k = keyAt(blockNumber, validatorIndex, true); + return k == null ? Optional.empty() : Optional.of(k.getH().clone()); + } + /** * D-081. The validator ADDRESS bound to a registry index at a height. * @@ -4000,7 +4173,11 @@ public final class FalconSealSupport { * @return the bound address, or null when nothing binds that index at that height */ public Address addressForIndexAtHistoric(final long blockNumber, final int validatorIndex) { - return addressAt(blockNumber, validatorIndex, true); + // AERE AIP-22 stage 3b: from H_k the index names the v3 row directly (its id), like its key + if (AerePqIdentity.keysFromV3(blockNumber)) { + return AerePqIdentity.v3IdAt(blockNumber, validatorIndex).orElse(null); + } + return identityOf(addressAt(blockNumber, validatorIndex, true), blockNumber); } /** @@ -4011,7 +4188,16 @@ public final class FalconSealSupport { * @return the bound address, or null */ public Address addressForIndexAtOwnHead(final long blockNumber, final int validatorIndex) { - return addressAt(blockNumber, validatorIndex, false); + // AERE AIP-22 stage 3b: from H_k the index names the v3 row directly (its id), like its key + if (AerePqIdentity.keysFromV3(blockNumber)) { + return AerePqIdentity.v3IdAt(blockNumber, validatorIndex).orElse(null); + } + return identityOf(addressAt(blockNumber, validatorIndex, false), blockNumber); + } + + /** AERE AIP-22 stage 3: the registry's classical address for an index, as the identity of the height. */ + private static Address identityOf(final Address registryAddress, final long blockNumber) { + return registryAddress == null ? null : AerePqIdentity.author(registryAddress, blockNumber); } /** 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 index 591ede1..8fd27c7 100644 --- 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 @@ -43,10 +43,20 @@ public final class HybridSealProducer { private final PqSchemeSchedule schedule; private final int validatorIndex; private final Map localKeys; + // AERE AIP-22 stage 3b-ii: this node's NEXT keys per scheme (rotations), and its local index by height (from H_k the index of + // its Falcon seal at that height, since an extra seal must sit at the index of its Falcon seal) + private final Map> nextKeys; + private final java.util.function.LongToIntFunction localIndexAt; + private final Map> v3KeyByEpoch = new java.util.concurrent.ConcurrentHashMap<>(); + // the origin of every anchor grid of the chain (AIP-22 stage 2: per-scheme intervals count from it) + private final long anchorBlock; // one shout per missing scheme, not one per block: a permanent alarm is learned and ignored private final Set alreadyShouted = new java.util.HashSet<>(); /** + * A producer for schedules WITHOUT per-scheme intervals (every named scheme at every anchor), + * which is every schedule written before AIP-22 stage 2; the grid origin is irrelevant then. + * * @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 @@ -57,10 +67,94 @@ public final class HybridSealProducer { final PqSchemeSchedule schedule, final int validatorIndex, final Map localKeys) { + this(attachFromBlock, schedule, validatorIndex, localKeys, 0L); + } + + /** + * @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 + * @param anchorBlock the chain's anchor activation height, origin of the per-scheme grids + */ + public HybridSealProducer( + final long attachFromBlock, + final PqSchemeSchedule schedule, + final int validatorIndex, + final Map localKeys, + final long anchorBlock) { this.attachFromBlock = schedule == null ? NEVER : attachFromBlock; this.schedule = schedule; this.validatorIndex = validatorIndex; this.localKeys = localKeys == null ? Map.of() : Map.copyOf(localKeys); + this.anchorBlock = anchorBlock; + this.nextKeys = Map.of(); + this.localIndexAt = h -> FalconSealSupport.instance().localIndexAt(h); + } + + /** + * AERE AIP-22 stage 3b-ii: a producer that also holds NEXT keys and resolves its index by height. + * + * @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 below H_k + * @param localKeys the private handles this node holds, per scheme id + * @param anchorBlock the chain's anchor activation height, origin of the per-scheme grids + * @param nextKeys the next private handles per scheme id, for v3 epochs that rotate this node's keys + * @param localIndexAt this node's index at a height (from H_k: the index of its Falcon seal there) + */ + public HybridSealProducer( + final long attachFromBlock, + final PqSchemeSchedule schedule, + final int validatorIndex, + final Map localKeys, + final long anchorBlock, + final Map> nextKeys, + final java.util.function.LongToIntFunction localIndexAt) { + this.attachFromBlock = schedule == null ? NEVER : attachFromBlock; + this.schedule = schedule; + this.validatorIndex = validatorIndex; + this.localKeys = localKeys == null ? Map.of() : Map.copyOf(localKeys); + this.anchorBlock = anchorBlock; + this.nextKeys = nextKeys == null ? Map.of() : Map.copyOf(nextKeys); + this.localIndexAt = localIndexAt; + } + + /** + * AERE AIP-22 stage 3b-ii: the key of a scheme this node signs with at a height from H_k, and the index it signs as: the local + * handle (base or next) whose probe signature verifies under the key of this node's row in the v3 epoch in force. + * + * @param scheme the scheme + * @param height the height + * @return the handle, or empty when this node holds no key of its row there (or has no row) + */ + public Optional v3KeyAt(final SealScheme scheme, final long height) { + final int index = localIndexAt.applyAsInt(height); + if (index < 0) { + return Optional.empty(); + } + final long bind = AerePqIdentity.epochAt(height).map(PqRegistryV3.Registry::bindHeight).orElse(-1L); + return v3KeyByEpoch.computeIfAbsent( + bind + ":" + index + ":" + scheme.id(), + k -> { + final Optional rowKey = AerePqIdentity.v3KeyAt(height, index, scheme.id()); + if (rowKey.isEmpty()) { + return Optional.empty(); + } + final List mine = new ArrayList<>(); + if (localKeys.get(scheme.id()) != null) { + mine.add(localKeys.get(scheme.id())); + } + mine.addAll(nextKeys.getOrDefault(scheme.id(), List.of())); + final byte[] probe = ("AERE-HYBRID-KEY-PROBE-V3:" + bind + ":" + index).getBytes(java.nio.charset.StandardCharsets.UTF_8); + for (final SealScheme.PrivateHandle h : mine) { + final Optional sig = scheme.sign(h, probe); + if (sig.isPresent() && scheme.verifyRaw(rowKey.get(), probe, sig.get())) { + return Optional.of(h); + } + } + return Optional.empty(); + }); } /** A producer that never emits anything: the configuration of every node today. */ @@ -84,6 +178,11 @@ public final class HybridSealProducer { *

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

The block being committed is the PARENT of the anchor the seals serve (the caller only asks + * at anchor parents, D-329), so the schemes are those the anchor at {@code blockNumber + 1} + * requires: with per-scheme intervals (AIP-22 stage 2) a scheme on a coarser grid is not + * signed at the parent of an anchor that does not demand it. + * * @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 @@ -93,7 +192,7 @@ public final class HybridSealProducer { return List.of(); } try { - final Set required = schedule.schemesAt(blockNumber); + final Set required = schedule.schemesRequiredForAnchor(blockNumber + 1L, anchorBlock); final List produced = new ArrayList<>(); for (final String schemeId : required) { if (SealSchemes.FALCON_512.id().equals(schemeId)) { @@ -105,7 +204,11 @@ public final class HybridSealProducer { + "' which this binary does not implement"); return List.of(); } - final SealScheme.PrivateHandle key = localKeys.get(schemeId); + // AERE AIP-22 stage 3b-ii: from H_k the key of this node's row in the v3 epoch in force, at its Falcon index there + final boolean v3 = AerePqIdentity.keysFromV3(blockNumber); + final SealScheme.PrivateHandle key = + v3 ? v3KeyAt(scheme.get(), blockNumber).orElse(null) : localKeys.get(schemeId); + final int index = v3 ? localIndexAt.applyAsInt(blockNumber) : validatorIndex; if (key == null) { shoutOnce(schemeId, "this node holds NO " + schemeId + " signing key, so it cannot produce the certificate the schedule requires from" @@ -118,7 +221,7 @@ public final class HybridSealProducer { return List.of(); } produced.add( - new SchemeSeal(scheme.get().wireId(), validatorIndex, Bytes.wrap(signature.get()))); + new SchemeSeal(scheme.get().wireId(), index, 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. 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 index 34f362d..2bb8679 100644 --- 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 @@ -164,6 +164,26 @@ public final class HybridSealSupport { throw new IllegalStateException( "AERE-PQC-HYBRID-CONF-04: unparseable " + PROPERTY_SCHEDULE + ": " + e.getMessage()); } + // AIP-22 stage 2: a per-scheme interval must be a multiple of the anchor interval this node + // runs, otherwise the scheme would be demanded at heights that are not anchors at all, and + // the two grids are only known together here. Refuse at startup, never run half-armed. + final PqAnchorConfig anchorConfig = + org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer.config(); + if (schedule.hasIntervals() && anchorConfig.anchorInterval().isPresent()) { + final int base = anchorConfig.anchorInterval().getAsInt(); + for (final PqSchemeSchedule.Step step : schedule.steps()) { + for (final Map.Entry e : step.intervals().entrySet()) { + if (e.getValue() % base != 0) { + throw new IllegalStateException( + "AERE-PQC-HYBRID-CONF-06: " + PROPERTY_SCHEDULE + " gives scheme '" + e.getKey() + + "' the interval " + e.getValue() + " at " + step.fromBlock() + + ", which is not a multiple of this node's anchor interval " + base + + " (" + PqAnchorConfig.PROPERTY_ANCHOR_INTERVAL + "); the scheme would be" + + " demanded at heights that carry no certificate. Refusing to start."); + } + } + } + } final HybridSignerRegistry registry; try { final Properties p = new Properties(); @@ -253,6 +273,35 @@ public final class HybridSealSupport { index); } + // AERE AIP-22 stage 3b-ii: this node's NEXT keys per scheme (aere.pq.hybrid.key..next, comma list, same file + // format). They belong to later v3 epochs, so no registry probe here: the startup guard checks each against its v3 row. + final Map> next = new HashMap<>(); + for (final SealScheme scheme : SealSchemes.all()) { + if (scheme.id().equals(SealSchemes.FALCON_512.id())) { + continue; + } + final String paths = reader.property(PROPERTY_KEY_PREFIX + scheme.id() + ".next"); + if (paths == null) { + continue; + } + for (final String np : com.google.common.base.Splitter.on(',').omitEmptyStrings().trimResults().split(paths)) { + if (np.isBlank()) { + continue; + } + try { + final Properties kp = new Properties(); + kp.load(new java.io.StringReader(new String(reader.file(np.trim()), StandardCharsets.UTF_8))); + final byte[] sk = Bytes.fromHexStringLenient(kp.getProperty("sk", "").trim()).toArray(); + next.computeIfAbsent(scheme.id(), x -> new java.util.ArrayList<>()) + .add(scheme.parsePrivateKey(sk).orElseThrow(() -> new IllegalStateException("bytes do not parse as a private key"))); + LOG.info("AERE HIBRID: loaded a NEXT {} signing key from {} (AIP-22 stage 3b-ii)", scheme.id(), np.trim()); + } catch (final IOException | RuntimeException e) { + throw new IllegalStateException( + "AERE-PQC-KEY-NEXT-01: cannot load the next " + scheme.id() + " key from '" + np.trim() + "': " + e.getMessage()); + } + } + } + if (attachFrom != HybridSealProducer.NEVER && keys.isEmpty()) { throw new IllegalStateException( "AERE-PQC-HYBRID-CONF-04: emission is armed from " + attachFrom + " but this node" @@ -263,7 +312,14 @@ public final class HybridSealSupport { final HybridSealProducer producer = keys.isEmpty() ? HybridSealProducer.disarmed() - : new HybridSealProducer(attachFrom, schedule, boundIndex, keys); + : new HybridSealProducer( + attachFrom, + schedule, + boundIndex, + keys, + anchorConfig.anchorBlock(), + next, + h -> FalconSealSupport.instance().localIndexAt(h)); return new HybridSealSupport(schedule, registry, producer); } @@ -292,4 +348,36 @@ public final class HybridSealSupport { public HybridSealProducer producer() { return producer; } + + /** + * AERE AIP-22 stage 3b-ii, THE STARTUP GUARD of the local hybrid keys: in every v3 epoch from H_k where this node's identity is + * registered, it must hold, for every non-Falcon scheme the schedule names at that epoch, the key of its row. Otherwise it would + * produce no hybrid seal from that epoch on while every configuration gate stays green; refuse to start instead. + * + * @param thisNodeAddress this node's own validator (ECDSA) address + */ + public void verifyLocalKeysCoverV3EpochsOrAbort(final org.hyperledger.besu.datatypes.Address thisNodeAddress) { + final Optional c = AerePqIdentity.installed(); + if (schedule == null || c.isEmpty() || c.get().keysFrom() == AerePqIdentity.NEVER || thisNodeAddress == null) { + return; + } + for (final PqRegistryV3.Registry e : c.get().epochs().tailMap(c.get().keysFrom(), true).values()) { + final org.hyperledger.besu.datatypes.Address me = AerePqIdentity.author(thisNodeAddress, e.bindHeight()); + if (e.rowOf(me.getBytes().toArrayUnsafe()).isEmpty()) { + continue; + } + for (final String schemeId : schedule.schemesAt(e.bindHeight())) { + if (schemeId.equals(SealSchemes.FALCON_512.id())) { + continue; + } + final Optional scheme = SealSchemes.byId(schemeId); + if (scheme.isEmpty() || producer.v3KeyAt(scheme.get(), e.bindHeight()).isEmpty()) { + throw new IllegalStateException( + "AERE-PQC-KEY-NEXT-02: REFUSING TO START. This node (id " + me + ") is registered in the v3 epoch bound at " + + e.bindHeight() + " but holds no " + schemeId + " key of its row there (aere.pq.hybrid.key." + schemeId + + "[.next]); from that height it would produce no hybrid seal."); + } + } + } + } } diff --git a/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqRegistryV3.java b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqRegistryV3.java new file mode 100644 index 0000000..3fb8a5c --- /dev/null +++ b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqRegistryV3.java @@ -0,0 +1,963 @@ +/* + * AERE AIP-22 stage 3: the validator registry v3 (post-quantum identity). + * + * WHAT IT IS. One registry epoch as a JSON manifest: rows keyed "0".."count-1" plus formatVersion 3, + * chainId, bindHeight and count at top level. Each row names a validator by a 20-byte `id` derived + * from its Falcon-512 key (AIP-22 section 3.1, the AIP-20 sender derivation with alg id 0x20): + * + * id = keccak256(0x50 || 0x20 || falcon512_public_key_897)[12:32] + * + * and binds to it one public key per seal scheme, each with a proof of possession. The id is taken + * at the first registration and KEPT through key rotations: a later epoch may change a row's keys + * only with a rotation signature by EVERY key the previous epoch bound to that id. The `ecdsa` + * field ({addr, claim}) is the verifiable link from a classical validator address to the id, and is + * required in the epoch that performs the switch at H_id. `transport` names the RLPx node key a + * validator's client uses, for the peer-forwarding predicate (AIP-22 section 3.4). + * + * THE CANONICAL FORMS, pinned here and in the conformance vectors (AIP-22 section 3.2 names them; + * this class is where their bytes are fixed, and the Nethermind port and the public JS tool must + * produce the same bytes from the same vector files): + * + * row pre-image = "AERE-PQ-ROW-3" || u8(3) || u64be(chainId) || u64be(bindHeight) || u32be(count) + * || u32be(index) || id(20) || u8(nKeys) + * || for each key in ascending wire id: u8(wireId) || u32be(len pk) || pk + * || u32be(len pop) || pop + * || u8(hasTransport) [|| transport(20)] + * row hash = keccak256(row pre-image) (rotation and ecdsa are NOT in it) + * Merkle root = binary tree over the row hashes in index order; node = keccak256(0x01 || l || r); + * an odd node at any level is paired with itself; one row: the root is its hash + * registry hash = keccak256("AERE-PQ-REGISTRY-3" || u64be(chainId) || u64be(bindHeight) + * || u32be(count) || root) + * pop message = keccak256("AERE-PQ-POP-3" || u8(3) || u64be(chainId) || u64be(bindHeight) + * || u32be(count) || u32be(index) || id(20) || u8(wireId) || u32be(len pk) || pk) + * rotate message = keccak256("AERE-PQ-ROTATE-3" || new row hash) + * claim digest = keccak256(0x19 || 0x00 || context || new row hash), context = the low 20 bytes + * of keccak256("AERE-PQ-CLAIM-1" || u64be(chainId)), the EIP-191 envelope of v2 + * + * The public key in the pre-images is the STANDARD encoding: Falcon-512 897 bytes (header 0x09 + + * the 896-byte h polynomial the v1/v2 registries store), SLH-DSA-SHA2-128s 32 bytes. Every field + * of the pop message earns its place for the reason given in PqRegistryBinding (domain tag against + * seal replay, key in the message against Falcon's missing exclusive ownership, chainId, index, + * bindHeight and count against lifting and truncation); the wire id keeps one scheme's proof from + * standing in for another's. The Merkle node tag 0x01 keeps an inner node from ever being read as + * a row hash, and the count bound into the registry hash keeps a duplicated odd leaf from making a + * tree of count+1 rows hash like one of count. + * + * LOADING IS STRICT, as for v1/v2: an ambiguous or incomplete file REFUSES with a named code, never + * hashes. Refusal codes AERE-PQC-REG3-01..16, listed at {@link Code}. + * + * WHAT IT DOES NOT DO (stated): it does not wire the ids into QBFT (the set translation at H_id, + * message authorship, coinbase, votes and peer forwarding are later parts of stage 3), and it + * does not pin the registry hash on chain (the anchor-contract slot or genesis pqRegistryHash + * does that, as today; {@link #loadPinned} compares against the pinned value). + */ +package org.hyperledger.besu.consensus.common.bft; + +import org.hyperledger.besu.crypto.SECPSignature; +import org.hyperledger.besu.crypto.SignatureAlgorithmFactory; +import org.hyperledger.besu.datatypes.Address; +import org.hyperledger.besu.datatypes.Hash; +import org.hyperledger.besu.ethereum.core.Util; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.TreeMap; + +import com.fasterxml.jackson.databind.JsonNode; +import org.apache.tuweni.bytes.Bytes; +import org.apache.tuweni.bytes.Bytes32; +import org.bouncycastle.crypto.digests.KeccakDigest; + +/** The v3 validator registry: canonical forms, strict loader, Merkle membership. */ +public final class PqRegistryV3 { + + /** Format version of this registry. */ + public static final int FORMAT_VERSION = 3; + + /** Domain tag of the row pre-image. */ + public static final String DOMAIN_ROW = "AERE-PQ-ROW-3"; + + /** Domain tag of the registry hash. */ + public static final String DOMAIN_REGISTRY = "AERE-PQ-REGISTRY-3"; + + /** Domain tag of the proof-of-possession message. */ + public static final String DOMAIN_POP = "AERE-PQ-POP-3"; + + /** Domain tag of the rotation message. */ + public static final String DOMAIN_ROTATE = "AERE-PQ-ROTATE-3"; + + /** Tag byte of an inner Merkle node. */ + public static final byte MERKLE_NODE_TAG = 0x01; + + /** First byte of the id pre-image: the AIP-20 transaction type. */ + public static final byte ID_TX_TYPE = 0x50; + + /** Second byte of the id pre-image: the algorithm id AIP-20 reserves for Falcon-512. */ + public static final byte ID_ALG_FALCON_512 = 0x20; + + /** Header byte of the standard Falcon-512 public key encoding. */ + public static final byte FALCON_512_PK_HEADER = 0x09; + + /** Standard Falcon-512 public key length (header + 896-byte h). */ + public static final int FALCON_512_PK_LENGTH = 897; + + private static final int ECDSA_SIGNATURE_LENGTH = 65; + + /** The named refusals of the strict loader. */ + public enum Code { + /** formatVersion missing or not 3. */ + REG3_01_FORMAT("AERE-PQC-REG3-01"), + /** chainId missing or not the node's chain. */ + REG3_02_CHAIN("AERE-PQC-REG3-02"), + /** count missing, rows not exactly "0".."count-1", or an unknown top-level or row field. */ + REG3_03_SHAPE("AERE-PQC-REG3-03"), + /** id missing, not hex, or not 20 bytes. */ + REG3_04_ID("AERE-PQC-REG3-04"), + /** a key names a scheme this build does not know. */ + REG3_05_UNKNOWN_SCHEME("AERE-PQC-REG3-05"), + /** a scheme the schedule requires has no key on a row. */ + REG3_06_REQUIRED_SCHEME("AERE-PQC-REG3-06"), + /** a public key is not in its scheme's standard encoding. */ + REG3_07_KEY_ENCODING("AERE-PQC-REG3-07"), + /** a proof of possession is missing or does not verify. */ + REG3_08_POP("AERE-PQC-REG3-08"), + /** a NEW id is not the derivation of the row's Falcon-512 key. */ + REG3_09_ID_DERIVATION("AERE-PQC-REG3-09"), + /** two rows carry the same id. */ + REG3_10_DUPLICATE_ID("AERE-PQC-REG3-10"), + /** two rows carry the same public key. */ + REG3_11_DUPLICATE_KEY("AERE-PQC-REG3-11"), + /** a changed row lacks a verifying rotation signature by every previous key of its id. */ + REG3_12_ROTATION_MISSING("AERE-PQC-REG3-12"), + /** a rotation is present where none belongs (unchanged row, or a new id). */ + REG3_13_ROTATION_UNEXPECTED("AERE-PQC-REG3-13"), + /** the ecdsa link is required and missing, does not recover to its address, or repeats one. */ + REG3_14_ECDSA("AERE-PQC-REG3-14"), + /** transport is malformed. */ + REG3_15_TRANSPORT("AERE-PQC-REG3-15"), + /** the computed registry hash is not the pinned (or the file's own declared) hash. */ + REG3_16_HASH("AERE-PQC-REG3-16"); + + private final String text; + + Code(final String text) { + this.text = text; + } + + /** + * The refusal code as printed. + * + * @return e.g. AERE-PQC-REG3-08 + */ + public String text() { + return text; + } + } + + /** A refusal of the strict loader, carrying its named code. */ + public static final class RegistryV3Exception extends RuntimeException { + private static final long serialVersionUID = 1L; + + /** The named refusal. */ + private final transient Code code; + + /** + * A refusal. + * + * @param code the named code + * @param message what was wrong, in words + */ + public RegistryV3Exception(final Code code, final String message) { + super(code.text() + ": " + message); + this.code = code; + } + + /** + * The named code. + * + * @return the code + */ + public Code code() { + return code; + } + } + + /** + * One key of a row. + * + * @param scheme the seal scheme + * @param publicKey the standard encoding (Falcon-512 897 bytes, SLH-DSA-SHA2-128s 32 bytes) + * @param pop the proof of possession by that key + */ + public record Key(SealScheme scheme, byte[] publicKey, byte[] pop) {} + + /** + * One row. + * + * @param index the row index + * @param id the 20-byte validator id + * @param keys the keys by wire id, ascending + * @param transport the RLPx node-key address, or null + * @param rotation scheme id to rotation signature (by the previous epoch's key of that scheme) + * @param ecdsaAddress the classical validator address, or null + * @param ecdsaClaim the EIP-191 claim by that address over the row hash, or null + */ + public record Row( + int index, + byte[] id, + TreeMap keys, + byte[] transport, + Map rotation, + byte[] ecdsaAddress, + byte[] ecdsaClaim) { + + /** + * The key of a scheme on this row. + * + * @param schemeId e.g. "falcon-512" + * @return the key, if the row carries one + */ + public Optional key(final String schemeId) { + for (final Key k : keys.values()) { + if (k.scheme().id().equals(schemeId)) { + return Optional.of(k); + } + } + return Optional.empty(); + } + } + + /** + * A loaded, verified epoch. + * + * @param chainId the chain + * @param bindHeight the activation height of this epoch + * @param count the number of rows + * @param rows the rows in index order + * @param rowHashes the row hashes in index order + * @param root the Merkle root over the row hashes + * @param hash the registry hash (what the chain pins) + */ + public record Registry( + long chainId, + long bindHeight, + int count, + List rows, + List rowHashes, + Bytes32 root, + Bytes32 hash) { + + /** + * The row of an id. + * + * @param id the 20-byte id + * @return the row, if the id is registered in this epoch + */ + public Optional rowOf(final byte[] id) { + for (final Row r : rows) { + if (Arrays.equals(r.id(), id)) { + return Optional.of(r); + } + } + return Optional.empty(); + } + + /** + * The row whose ecdsa link names this classical address. + * + * @param address the 20-byte address + * @return the row, if one links it + */ + public Optional rowOfEcdsa(final byte[] address) { + for (final Row r : rows) { + if (r.ecdsaAddress() != null && Arrays.equals(r.ecdsaAddress(), address)) { + return Optional.of(r); + } + } + return Optional.empty(); + } + } + + private PqRegistryV3() {} + + // ============================================================================================= + // Canonical forms + // ============================================================================================= + + /** + * The validator id of a Falcon-512 key (AIP-22 section 3.1). + * + * @param falconStandard897 the 897-byte standard encoding + * @return the 20-byte id + */ + public static byte[] idOf(final byte[] falconStandard897) { + final byte[] in = new byte[2 + falconStandard897.length]; + in[0] = ID_TX_TYPE; + in[1] = ID_ALG_FALCON_512; + System.arraycopy(falconStandard897, 0, in, 2, falconStandard897.length); + return Arrays.copyOfRange(keccak(in), 12, 32); + } + + /** + * The message a row key signs to prove possession. + * + * @param chainId the chain + * @param bindHeight the epoch's activation height + * @param count the number of rows + * @param index the row index + * @param id the row id + * @param wireId the scheme wire id + * @param publicKey the standard encoding of the key + * @return the 32-byte message + */ + public static Bytes32 popMessage( + final long chainId, + final long bindHeight, + final int count, + final int index, + final byte[] id, + final byte wireId, + final byte[] publicKey) { + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + writeAll(out, DOMAIN_POP.getBytes(StandardCharsets.US_ASCII)); + out.write(FORMAT_VERSION); + writeAll(out, uint64be(chainId)); + writeAll(out, uint64be(bindHeight)); + writeAll(out, uint32be(count)); + writeAll(out, uint32be(index)); + writeAll(out, id); + out.write(wireId); + writeAll(out, uint32be(publicKey.length)); + writeAll(out, publicKey); + return Bytes32.wrap(keccak(out.toByteArray())); + } + + /** + * The canonical pre-image of a row (without rotation and ecdsa). + * + * @param chainId the chain + * @param bindHeight the epoch's activation height + * @param count the number of rows + * @param row the row + * @return the pre-image + */ + public static byte[] rowPreimage( + final long chainId, final long bindHeight, final int count, final Row row) { + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + writeAll(out, DOMAIN_ROW.getBytes(StandardCharsets.US_ASCII)); + out.write(FORMAT_VERSION); + writeAll(out, uint64be(chainId)); + writeAll(out, uint64be(bindHeight)); + writeAll(out, uint32be(count)); + writeAll(out, uint32be(row.index())); + writeAll(out, row.id()); + out.write(row.keys().size()); + for (final Map.Entry e : row.keys().entrySet()) { + out.write(e.getKey()); + writeAll(out, uint32be(e.getValue().publicKey().length)); + writeAll(out, e.getValue().publicKey()); + writeAll(out, uint32be(e.getValue().pop().length)); + writeAll(out, e.getValue().pop()); + } + if (row.transport() == null) { + out.write(0); + } else { + out.write(1); + writeAll(out, row.transport()); + } + return out.toByteArray(); + } + + /** + * The row hash. + * + * @param chainId the chain + * @param bindHeight the epoch's activation height + * @param count the number of rows + * @param row the row + * @return keccak256 of the row pre-image + */ + public static Bytes32 rowHash( + final long chainId, final long bindHeight, final int count, final Row row) { + return Bytes32.wrap(keccak(rowPreimage(chainId, bindHeight, count, row))); + } + + /** + * The Merkle root over row hashes in index order. + * + * @param leaves the row hashes + * @return the root + */ + public static Bytes32 merkleRoot(final List leaves) { + if (leaves.isEmpty()) { + throw new IllegalArgumentException("a registry has at least one row"); + } + List level = leaves; + while (level.size() > 1) { + final List next = new ArrayList<>(); + for (int i = 0; i < level.size(); i += 2) { + final Bytes32 l = level.get(i); + final Bytes32 r = i + 1 < level.size() ? level.get(i + 1) : l; + next.add(node(l, r)); + } + level = next; + } + return level.get(0); + } + + /** + * The sibling path of one leaf, bottom-up. + * + * @param leaves the row hashes + * @param index the leaf index + * @return the siblings from the leaf level up + */ + public static List merkleProof(final List leaves, final int index) { + if (index < 0 || index >= leaves.size()) { + throw new IllegalArgumentException("index " + index + " is not a row of " + leaves.size()); + } + final List proof = new ArrayList<>(); + List level = leaves; + int i = index; + while (level.size() > 1) { + final int sib = (i % 2 == 0) ? i + 1 : i - 1; + proof.add(sib < level.size() ? level.get(sib) : level.get(i)); + final List next = new ArrayList<>(); + for (int j = 0; j < level.size(); j += 2) { + final Bytes32 l = level.get(j); + final Bytes32 r = j + 1 < level.size() ? level.get(j + 1) : l; + next.add(node(l, r)); + } + level = next; + i /= 2; + } + return proof; + } + + /** + * Checks a membership proof. The index MUST be below count: with the odd leaf paired with + * itself, a path for index == count would otherwise verify against a count-row tree. + * + * @param leaf the row hash + * @param index the claimed index + * @param count the number of rows + * @param proof the siblings, bottom-up + * @param root the root + * @return whether the leaf is row {@code index} of the tree with that root + */ + public static boolean verifyMerkleProof( + final Bytes32 leaf, + final int index, + final int count, + final List proof, + final Bytes32 root) { + if (index < 0 || index >= count || count < 1) { + return false; + } + int depth = 0; + for (int n = count; n > 1; n = (n + 1) / 2) { + depth++; + } + if (proof.size() != depth) { + return false; + } + Bytes32 acc = leaf; + int i = index; + for (final Bytes32 sib : proof) { + acc = (i % 2 == 0) ? node(acc, sib) : node(sib, acc); + i /= 2; + } + return acc.equals(root); + } + + /** + * The registry hash the chain pins. + * + * @param chainId the chain + * @param bindHeight the epoch's activation height + * @param count the number of rows + * @param root the Merkle root + * @return the hash + */ + public static Bytes32 registryHash( + final long chainId, final long bindHeight, final int count, final Bytes32 root) { + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + writeAll(out, DOMAIN_REGISTRY.getBytes(StandardCharsets.US_ASCII)); + writeAll(out, uint64be(chainId)); + writeAll(out, uint64be(bindHeight)); + writeAll(out, uint32be(count)); + writeAll(out, root.toArrayUnsafe()); + return Bytes32.wrap(keccak(out.toByteArray())); + } + + /** + * The message every previous key of an id signs to authorise a changed row. + * + * @param newRowHash the hash of the new row + * @return the 32-byte message + */ + public static Bytes32 rotateMessage(final Bytes32 newRowHash) { + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + writeAll(out, DOMAIN_ROTATE.getBytes(StandardCharsets.US_ASCII)); + writeAll(out, newRowHash.toArrayUnsafe()); + return Bytes32.wrap(keccak(out.toByteArray())); + } + + /** + * The digest the classical validator key signs to link its address to the row. + * + * @param chainId the chain + * @param rowHash the row hash + * @return the EIP-191 version-0 digest + */ + public static Bytes32 claimDigest(final long chainId, final Bytes32 rowHash) { + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + out.write(0x19); + out.write(0x00); + writeAll(out, PqRegistryBinding.claimContext(chainId)); + writeAll(out, rowHash.toArrayUnsafe()); + return Bytes32.wrap(keccak(out.toByteArray())); + } + + // ============================================================================================= + // The strict loader + // ============================================================================================= + + /** + * Loads and verifies an epoch and compares its hash with the pinned one. + * + * @param json the manifest + * @param chainId the node's chain + * @param requiredSchemes the scheme ids every row must carry + * @param previous the previous v3 epoch (for rotations), or null for the first + * @param ecdsaRequired whether every row must carry the ecdsa link (the switch epoch) + * @param pinnedHash the hash the chain pins + * @return the verified epoch + */ + public static Registry loadPinned( + final JsonNode json, + final long chainId, + final List requiredSchemes, + final Registry previous, + final boolean ecdsaRequired, + final Bytes32 pinnedHash) { + final Registry r = load(json, chainId, requiredSchemes, previous, ecdsaRequired); + if (!r.hash().equals(pinnedHash)) { + throw new RegistryV3Exception( + Code.REG3_16_HASH, + "the registry hashes to " + r.hash() + ", the chain pins " + pinnedHash + "; refusing"); + } + return r; + } + + /** + * Loads and verifies an epoch (every proof, every uniqueness rule, every rotation). + * + * @param json the manifest + * @param chainId the node's chain + * @param requiredSchemes the scheme ids every row must carry + * @param previous the previous v3 epoch (for rotations), or null for the first + * @param ecdsaRequired whether every row must carry the ecdsa link + * @return the verified epoch + */ + public static Registry load( + final JsonNode json, + final long chainId, + final List requiredSchemes, + final Registry previous, + final boolean ecdsaRequired) { + if (json == null || !json.isObject()) { + throw new RegistryV3Exception(Code.REG3_03_SHAPE, "the manifest is not a JSON object"); + } + if (!json.has("formatVersion") || json.get("formatVersion").asInt(-1) != FORMAT_VERSION + || !json.get("formatVersion").isIntegralNumber()) { + throw new RegistryV3Exception(Code.REG3_01_FORMAT, "formatVersion must be the number 3"); + } + if (!json.has("chainId") || !json.get("chainId").isIntegralNumber() + || json.get("chainId").asLong() != chainId) { + throw new RegistryV3Exception( + Code.REG3_02_CHAIN, "chainId must be the number " + chainId + " (this node's chain)"); + } + if (!json.has("bindHeight") || !json.get("bindHeight").isIntegralNumber() + || json.get("bindHeight").asLong() < 0) { + throw new RegistryV3Exception(Code.REG3_03_SHAPE, "bindHeight must be a number >= 0"); + } + if (!json.has("count") || !json.get("count").isIntegralNumber() + || json.get("count").asInt() < 1) { + throw new RegistryV3Exception(Code.REG3_03_SHAPE, "count must be a number >= 1"); + } + final long bindHeight = json.get("bindHeight").asLong(); + final int count = json.get("count").asInt(); + final Set allowedTop = new HashSet<>(); + Collections.addAll(allowedTop, "formatVersion", "chainId", "bindHeight", "count", "registryHash"); + for (int i = 0; i < count; i++) { + allowedTop.add(Integer.toString(i)); + } + final Iterator names = json.fieldNames(); + while (names.hasNext()) { + final String n = names.next(); + if (!allowedTop.contains(n)) { + throw new RegistryV3Exception( + Code.REG3_03_SHAPE, + "unexpected top-level field '" + n + "' (rows are exactly \"0\"..\"" + (count - 1) + + "\")"); + } + } + final List rows = new ArrayList<>(); + for (int i = 0; i < count; i++) { + final JsonNode e = json.get(Integer.toString(i)); + if (e == null || !e.isObject()) { + throw new RegistryV3Exception(Code.REG3_03_SHAPE, "row " + i + " is missing"); + } + rows.add(parseRow(i, e)); + } + // required schemes, key encodings, possession proofs + for (final Row row : rows) { + for (final String s : requiredSchemes) { + if (row.key(s).isEmpty()) { + throw new RegistryV3Exception( + Code.REG3_06_REQUIRED_SCHEME, "row " + row.index() + " has no " + s + " key"); + } + } + if (row.key(SealSchemes.FALCON_512.id()).isEmpty()) { + throw new RegistryV3Exception( + Code.REG3_06_REQUIRED_SCHEME, + "row " + row.index() + " has no falcon-512 key; the id is derived from it"); + } + for (final Key k : row.keys().values()) { + final byte[] registryForm = registryForm(row.index(), k); + final Bytes32 msg = + popMessage( + chainId, bindHeight, count, row.index(), row.id(), k.scheme().wireId(), + k.publicKey()); + if (k.pop() == null || k.pop().length == 0 + || !k.scheme().verifyRaw(registryForm, msg.toArrayUnsafe(), k.pop())) { + throw new RegistryV3Exception( + Code.REG3_08_POP, + "row " + row.index() + " " + k.scheme().id() + + ": the proof of possession does not verify for chainId=" + chainId + + " bindHeight=" + bindHeight + " count=" + count + " index=" + row.index()); + } + } + } + // uniqueness of ids and of every public key + final Set ids = new HashSet<>(); + final Set keys = new HashSet<>(); + for (final Row row : rows) { + if (!ids.add(Bytes.wrap(row.id()))) { + throw new RegistryV3Exception( + Code.REG3_10_DUPLICATE_ID, "id 0x" + hex(row.id()) + " appears on two rows"); + } + for (final Key k : row.keys().values()) { + if (!keys.add(Bytes.wrap(k.publicKey()))) { + throw new RegistryV3Exception( + Code.REG3_11_DUPLICATE_KEY, + "row " + row.index() + " " + k.scheme().id() + ": the key appears on another row"); + } + } + } + // row hashes, then ids and rotations against the previous epoch + final List hashes = new ArrayList<>(); + for (final Row row : rows) { + hashes.add(rowHash(chainId, bindHeight, count, row)); + } + for (final Row row : rows) { + final Optional prev = previous == null ? Optional.empty() : previous.rowOf(row.id()); + if (prev.isEmpty()) { + final byte[] derived = idOf(row.key(SealSchemes.FALCON_512.id()).orElseThrow().publicKey()); + if (!Arrays.equals(derived, row.id())) { + throw new RegistryV3Exception( + Code.REG3_09_ID_DERIVATION, + "row " + row.index() + ": a new id must be keccak256(0x50||0x20||falcon)[12:32] = 0x" + + hex(derived) + ", the row says 0x" + hex(row.id())); + } + if (!row.rotation().isEmpty()) { + throw new RegistryV3Exception( + Code.REG3_13_ROTATION_UNEXPECTED, + "row " + row.index() + " is a new id and carries a rotation"); + } + continue; + } + final boolean changed = !sameKeys(prev.get(), row); + if (!changed) { + if (!row.rotation().isEmpty()) { + throw new RegistryV3Exception( + Code.REG3_13_ROTATION_UNEXPECTED, + "row " + row.index() + " did not change its keys and carries a rotation"); + } + continue; + } + final Bytes32 rot = rotateMessage(hashes.get(row.index())); + for (final Key old : prev.get().keys().values()) { + final byte[] sig = row.rotation().get(old.scheme().id()); + if (sig == null + || !old.scheme() + .verifyRaw(registryForm(prev.get().index(), old), rot.toArrayUnsafe(), sig)) { + throw new RegistryV3Exception( + Code.REG3_12_ROTATION_MISSING, + "row " + row.index() + " changed its keys; the previous " + old.scheme().id() + + " key of id 0x" + hex(row.id()) + " did not sign the new row (every previous" + + " key must)"); + } + } + if (row.rotation().size() != prev.get().keys().size()) { + throw new RegistryV3Exception( + Code.REG3_13_ROTATION_UNEXPECTED, + "row " + row.index() + " carries a rotation signature for a scheme the previous row" + + " did not bind"); + } + } + // the ecdsa link + final Set ecdsa = new HashSet<>(); + for (final Row row : rows) { + if (row.ecdsaAddress() == null) { + if (ecdsaRequired) { + throw new RegistryV3Exception( + Code.REG3_14_ECDSA, + "row " + row.index() + " has no ecdsa link; this epoch performs the switch and" + + " every row must carry one"); + } + continue; + } + final Address rec = recover(claimDigest(chainId, hashes.get(row.index())), row.ecdsaClaim()); + if (rec == null || !Arrays.equals(rec.getBytes().toArrayUnsafe(), row.ecdsaAddress())) { + throw new RegistryV3Exception( + Code.REG3_14_ECDSA, + "row " + row.index() + ": the ecdsa claim does not recover to 0x" + + hex(row.ecdsaAddress())); + } + if (!ecdsa.add(Bytes.wrap(row.ecdsaAddress()))) { + throw new RegistryV3Exception( + Code.REG3_14_ECDSA, "row " + row.index() + ": the ecdsa address appears on two rows"); + } + } + final Bytes32 root = merkleRoot(hashes); + final Bytes32 hash = registryHash(chainId, bindHeight, count, root); + if (json.has("registryHash")) { + final Bytes32 declared = parseBytes32(json.get("registryHash"), "registryHash"); + if (!declared.equals(hash)) { + throw new RegistryV3Exception( + Code.REG3_16_HASH, + "the file declares registryHash " + declared + " and hashes to " + hash); + } + } + return new Registry( + chainId, + bindHeight, + count, + Collections.unmodifiableList(rows), + Collections.unmodifiableList(hashes), + root, + hash); + } + + // ============================================================================================= + // Parsing + // ============================================================================================= + + private static Row parseRow(final int index, final JsonNode e) { + final Set allowed = Set.of("id", "keys", "pop", "rotation", "ecdsa", "transport"); + final Iterator fields = e.fieldNames(); + while (fields.hasNext()) { + final String f = fields.next(); + if (!allowed.contains(f)) { + throw new RegistryV3Exception( + Code.REG3_03_SHAPE, "row " + index + " has an unknown field '" + f + "'"); + } + } + final byte[] id = parseHex(e.get("id"), 20, Code.REG3_04_ID, "row " + index + " id"); + final JsonNode keysNode = e.get("keys"); + final JsonNode popNode = e.get("pop"); + if (keysNode == null || !keysNode.isObject() || keysNode.size() == 0) { + throw new RegistryV3Exception(Code.REG3_03_SHAPE, "row " + index + " has no keys"); + } + if (popNode == null || !popNode.isObject()) { + throw new RegistryV3Exception(Code.REG3_08_POP, "row " + index + " has no pop object"); + } + final TreeMap keys = new TreeMap<>(); + final Iterator> it = keysNode.fields(); + while (it.hasNext()) { + final Map.Entry k = it.next(); + final SealScheme scheme = + SealSchemes.byId(k.getKey()) + .orElseThrow( + () -> + new RegistryV3Exception( + Code.REG3_05_UNKNOWN_SCHEME, + "row " + index + " names the unknown scheme '" + k.getKey() + "'")); + final int len = + scheme == SealSchemes.FALCON_512 ? FALCON_512_PK_LENGTH : scheme.publicKeyLength(); + final byte[] pk = + parseHex(k.getValue(), len, Code.REG3_07_KEY_ENCODING, "row " + index + " " + k.getKey()); + if (scheme == SealSchemes.FALCON_512 && pk[0] != FALCON_512_PK_HEADER) { + throw new RegistryV3Exception( + Code.REG3_07_KEY_ENCODING, + "row " + index + " falcon-512: the standard encoding starts with 0x09"); + } + final JsonNode p = popNode.get(k.getKey()); + if (p == null) { + throw new RegistryV3Exception( + Code.REG3_08_POP, "row " + index + " " + k.getKey() + " has no proof of possession"); + } + final byte[] pop = parseHex(p, -1, Code.REG3_08_POP, "row " + index + " pop " + k.getKey()); + keys.put(scheme.wireId() & 0xff, new Key(scheme, pk, pop)); + } + if (popNode.size() != keysNode.size()) { + throw new RegistryV3Exception( + Code.REG3_08_POP, "row " + index + " carries a proof for a scheme it has no key for"); + } + byte[] transport = null; + final JsonNode t = e.get("transport"); + if (t != null) { + if (!t.isObject() || t.size() != 1 || !t.has("secp256k1Address")) { + throw new RegistryV3Exception( + Code.REG3_15_TRANSPORT, "row " + index + " transport must be {secp256k1Address}"); + } + transport = + parseHex(t.get("secp256k1Address"), 20, Code.REG3_15_TRANSPORT, "row " + index + " transport"); + } + final Map rotation = new LinkedHashMap<>(); + final JsonNode rot = e.get("rotation"); + if (rot != null) { + if (!rot.isObject() || rot.size() == 0) { + throw new RegistryV3Exception( + Code.REG3_13_ROTATION_UNEXPECTED, "row " + index + " rotation must be a non-empty map"); + } + final Iterator> ri = rot.fields(); + while (ri.hasNext()) { + final Map.Entry r = ri.next(); + if (SealSchemes.byId(r.getKey()).isEmpty()) { + throw new RegistryV3Exception( + Code.REG3_05_UNKNOWN_SCHEME, + "row " + index + " rotation names the unknown scheme '" + r.getKey() + "'"); + } + rotation.put( + r.getKey(), + parseHex(r.getValue(), -1, Code.REG3_12_ROTATION_MISSING, "row " + index + " rotation")); + } + } + byte[] ecdsaAddress = null; + byte[] ecdsaClaim = null; + final JsonNode ec = e.get("ecdsa"); + if (ec != null) { + if (!ec.isObject() || ec.size() != 2 || !ec.has("addr") || !ec.has("claim")) { + throw new RegistryV3Exception( + Code.REG3_14_ECDSA, "row " + index + " ecdsa must be {addr, claim}"); + } + ecdsaAddress = parseHex(ec.get("addr"), 20, Code.REG3_14_ECDSA, "row " + index + " ecdsa.addr"); + ecdsaClaim = + parseHex( + ec.get("claim"), ECDSA_SIGNATURE_LENGTH, Code.REG3_14_ECDSA, "row " + index + " claim"); + } + return new Row(index, id, keys, transport, rotation, ecdsaAddress, ecdsaClaim); + } + + /** The form a scheme's parser takes: Falcon without the header byte, others as stored. */ + private static byte[] registryForm(final int index, final Key k) { + if (k.scheme() == SealSchemes.FALCON_512) { + if (k.publicKey().length != FALCON_512_PK_LENGTH) { + throw new RegistryV3Exception( + Code.REG3_07_KEY_ENCODING, "row " + index + " falcon-512 key is not 897 bytes"); + } + return Arrays.copyOfRange(k.publicKey(), 1, FALCON_512_PK_LENGTH); + } + return k.publicKey(); + } + + private static boolean sameKeys(final Row a, final Row b) { + if (a.keys().size() != b.keys().size()) { + return false; + } + for (final Map.Entry e : a.keys().entrySet()) { + final Key other = b.keys().get(e.getKey()); + if (other == null || !Arrays.equals(other.publicKey(), e.getValue().publicKey())) { + return false; + } + } + return true; + } + + private static Address recover(final Bytes32 digest, final byte[] signature) { + if (signature == null || signature.length != ECDSA_SIGNATURE_LENGTH) { + return null; + } + try { + final SECPSignature s = + SignatureAlgorithmFactory.getInstance().decodeSignature(Bytes.wrap(signature)); + return Util.signatureToAddress(s, Hash.wrap(digest)); + } catch (final RuntimeException ex) { + return null; + } + } + + private static byte[] parseHex( + final JsonNode n, final int len, final Code code, final String what) { + if (n == null || !n.isTextual()) { + throw new RegistryV3Exception(code, what + " must be a 0x-prefixed hex string"); + } + final String s = n.asText(); + if (!s.startsWith("0x") || (s.length() % 2) != 0 || !s.substring(2).matches("[0-9a-f]*")) { + throw new RegistryV3Exception(code, what + " is not lowercase 0x-hex of even length"); + } + final byte[] b = Bytes.fromHexString(s).toArrayUnsafe(); + if (len >= 0 && b.length != len) { + throw new RegistryV3Exception(code, what + " is " + b.length + " bytes, expected " + len); + } + return b; + } + + private static Bytes32 parseBytes32(final JsonNode n, final String what) { + return Bytes32.wrap(parseHex(n, 32, Code.REG3_16_HASH, what)); + } + + private static Bytes32 node(final Bytes32 l, final Bytes32 r) { + final byte[] in = new byte[65]; + in[0] = MERKLE_NODE_TAG; + System.arraycopy(l.toArrayUnsafe(), 0, in, 1, 32); + System.arraycopy(r.toArrayUnsafe(), 0, in, 33, 32); + return Bytes32.wrap(keccak(in)); + } + + private static void writeAll(final ByteArrayOutputStream out, final byte[] b) { + out.write(b, 0, b.length); + } + + private static byte[] uint32be(final int 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 >>> (56 - 8 * i)); + } + return b; + } + + private static byte[] keccak(final byte[] input) { + final KeccakDigest d = new KeccakDigest(256); + d.update(input, 0, input.length); + final byte[] out = new byte[32]; + d.doFinal(out, 0); + return out; + } + + private static String hex(final byte[] b) { + final StringBuilder sb = new StringBuilder(b.length * 2); + for (final byte x : b) { + sb.append(String.format(Locale.ROOT, "%02x", x & 0xff)); + } + return sb.toString(); + } +} 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 index a4d1bd4..6a706ed 100644 --- 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 @@ -7,6 +7,17 @@ * Changing the mathematics of the chain becomes one property plus keys, never a code edit - * that is the whole point of the abstraction layer. * + * PER-SCHEME INTERVALS (AIP-22 stage 2, 2026-09-23). A scheme in a step may carry its own anchor + * interval, "scheme/interval": "H:falcon-512/32+slh-dsa-sha2-128s/128" reads "from H, Falcon-512 + * is required at every anchor height on the 32 grid and SLH-DSA-SHA2-128s only at the anchor + * heights on the 128 grid", both grids counted from the chain's anchor activation height H0 (the + * one every anchor grid of this chain is counted from, so an anchor of the coarse grid is always + * an anchor of the fine one). A scheme without "/interval" is required at EVERY anchor height, + * which is exactly the behaviour before this change, so an existing schedule keeps its meaning + * byte for byte. The intervals are validated as positive multiples of the anchor grid unit; the + * cross-check against the interval the node actually runs is done where both are known + * (HybridSealSupport at load), never assumed here. + * * 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 @@ -23,16 +34,42 @@ package org.hyperledger.besu.consensus.common.bft; import com.google.common.base.Splitter; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; 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) {} + /** + * The unit every anchor grid of an Aere chain is a multiple of (the base anchor interval of both + * chain 2800 and testnet 28001). A per-scheme interval that is not a multiple of it could never + * land on anchor heights, so it is refused at parse time. + */ + public static final int GRID_UNIT = 32; + + /** + * One step: from {@code fromBlock} (inclusive) the anchor carries {@code schemeIds}; a scheme + * present in {@code intervals} is required only at anchor heights on that interval's grid, + * a scheme absent from it at every anchor height. + */ + public record Step(long fromBlock, Set schemeIds, Map intervals) { + /** Canonical form: defensive copies, never null. */ + public Step( + final long fromBlock, final Set schemeIds, final Map intervals) { + this.fromBlock = fromBlock; + this.schemeIds = Set.copyOf(schemeIds); + this.intervals = intervals == null ? Map.of() : Map.copyOf(intervals); + } + + /** A step whose schemes are all required at every anchor height (the pre-AIP-22 form). */ + public Step(final long fromBlock, final Set schemeIds) { + this(fromBlock, schemeIds, Map.of()); + } + } private final List steps; @@ -40,8 +77,9 @@ public final class PqSchemeSchedule { 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. */ + /** Parse "H:scheme[/interval][+scheme[/interval]...],H:...". Refuses the WHOLE schedule on any + * defect: unknown or repeated scheme in a step, empty step, non-increasing heights, negative + * height, an interval that is not a positive multiple of {@link #GRID_UNIT}, garbage. */ public static PqSchemeSchedule parse(final String raw) { if (raw == null || raw.isBlank()) { throw new IllegalArgumentException("AERE PQ ORAR-SCHEME: empty schedule"); @@ -74,12 +112,15 @@ public final class PqSchemeSchedule { } lastHeight = h; final Set schemes = new LinkedHashSet<>(); + final Map intervals = new LinkedHashMap<>(); for (final String id : Splitter.on('+').split(parti.get(1))) { - final String curat = id.trim(); - if (curat.isEmpty()) { + final String piece = id.trim(); + if (piece.isEmpty()) { throw new IllegalArgumentException( "AERE PQ ORAR-SCHEME: step at " + h + " carries an empty scheme name"); } + final int slash = piece.indexOf('/'); + final String curat = slash < 0 ? piece : piece.substring(0, slash).trim(); if (SealSchemes.byId(curat).isEmpty()) { throw new IllegalArgumentException( "AERE PQ ORAR-SCHEME: step at " + h + " names UNKNOWN scheme '" + curat @@ -89,23 +130,41 @@ public final class PqSchemeSchedule { throw new IllegalArgumentException( "AERE PQ ORAR-SCHEME: step at " + h + " repeats scheme '" + curat + "'"); } + if (slash >= 0) { + final String rawInterval = piece.substring(slash + 1).trim(); + final int interval; + try { + interval = Integer.parseInt(rawInterval); + } catch (final NumberFormatException e) { + throw new IllegalArgumentException( + "AERE PQ ORAR-SCHEME: step at " + h + " gives scheme '" + curat + + "' the interval '" + rawInterval + "', which is not a number"); + } + if (interval <= 0 || interval % GRID_UNIT != 0) { + throw new IllegalArgumentException( + "AERE PQ ORAR-SCHEME: step at " + h + " gives scheme '" + curat + + "' the interval " + interval + ", which is not a positive multiple of the" + + " anchor grid unit " + GRID_UNIT + " - such a scheme could never land on an" + + " anchor height"); + } + intervals.put(curat, interval); + } } 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))); + steps.add(new Step(h, schemes, intervals)); } 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(); + /** The step in force at {@code height}, or null before the first one. */ + private Step stepAt(final long height) { + Step inForce = null; for (final Step s : steps) { if (s.fromBlock() <= height) { - inForce = s.schemeIds(); + inForce = s; } else { break; } @@ -113,6 +172,57 @@ public final class PqSchemeSchedule { return inForce; } + /** 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). This is the set a + * node must hold KEYS for and the set the armability gate checks; which of them an anchor + * actually demands is {@link #schemesRequiredForAnchor}. */ + public Set schemesAt(final long height) { + final Step s = stepAt(height); + return s == null ? Set.of() : s.schemeIds(); + } + + /** + * The schemes an anchor at {@code anchorHeight} must carry, given that its scheme step is read + * at the PARENT height (the seals of a certificate are made when the parent is committed, so + * every consumer of this schedule already indexes it by the parent), and that a scheme with a + * per-scheme interval is required only when {@code (anchorHeight - anchorBlock)} is a multiple + * of that interval. Without per-scheme intervals this is exactly {@code schemesAt(anchorHeight + * - 1)}. + * + * @param anchorHeight the anchor height being produced, enforced or validated + * @param anchorBlock the chain's anchor activation height, the origin of every anchor grid + * @return the required scheme ids, possibly a strict subset of the step's schemes + */ + public Set schemesRequiredForAnchor(final long anchorHeight, final long anchorBlock) { + final Step s = stepAt(anchorHeight - 1); + if (s == null) { + return Set.of(); + } + if (s.intervals().isEmpty()) { + return s.schemeIds(); + } + final Set required = new LinkedHashSet<>(); + for (final String id : s.schemeIds()) { + final Integer interval = s.intervals().get(id); + if (interval == null || onGrid(anchorHeight, anchorBlock, interval)) { + required.add(id); + } + } + return Set.copyOf(required); + } + + private static boolean onGrid(final long height, final long origin, final int interval) { + if (height < origin) { + return false; + } + return (height - origin) % interval == 0; + } + + /** Whether any step carries a per-scheme interval (then the grid cross-check applies). */ + public boolean hasIntervals() { + return steps.stream().anyMatch(s -> !s.intervals().isEmpty()); + } + /** All steps, ascending. */ public List steps() { return steps; diff --git a/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/blockcreation/BftBlockCreatorFactory.java b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/blockcreation/BftBlockCreatorFactory.java index 1ecdcbf..cefa0a0 100644 --- a/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/blockcreation/BftBlockCreatorFactory.java +++ b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/blockcreation/BftBlockCreatorFactory.java @@ -23,6 +23,7 @@ package org.hyperledger.besu.consensus.common.bft.blockcreation; import static com.google.common.base.Preconditions.checkState; import org.hyperledger.besu.config.BftConfigOptions; +import org.hyperledger.besu.consensus.common.bft.AerePqIdentity; import org.hyperledger.besu.consensus.common.ConsensusHelpers; import org.hyperledger.besu.consensus.common.ForksSchedule; import org.hyperledger.besu.consensus.common.bft.BftContext; @@ -119,7 +120,8 @@ public class BftBlockCreatorFactory { return new BftBlockCreator( miningConfiguration, forksSchedule, - localAddress, + AerePqIdentity.local( + localAddress, protocolContext.getBlockchain().getChainHeadBlockNumber() + 1L), ph -> createExtraData(round, ph), transactionPool, protocolContext, @@ -186,7 +188,10 @@ public class BftBlockCreatorFactory { validatorProvider.getVoteProviderAfterBlock(parentHeader); checkState(voteProviderAfterBlock.isPresent(), "Bft requires a vote provider"); final Optional proposal = - voteProviderAfterBlock.get().getVoteAfterBlock(parentHeader, localAddress); + voteProviderAfterBlock + .get() + .getVoteAfterBlock( + parentHeader, AerePqIdentity.local(localAddress, parentHeader.getNumber() + 1L)); final List

validators = new ArrayList<>(validatorProvider.getValidatorsAfterBlock(parentHeader)); 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 a46b7d8..e8fc683 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 @@ -16,6 +16,7 @@ package org.hyperledger.besu.consensus.common.bft.blockcreation; import org.apache.tuweni.bytes.Bytes; import org.hyperledger.besu.datatypes.Hash; +import org.hyperledger.besu.consensus.common.bft.AerePqIdentity; import org.hyperledger.besu.consensus.common.bft.BftContext; import org.hyperledger.besu.consensus.common.bft.BftExtraData; import org.hyperledger.besu.consensus.common.bft.FalconSeal; @@ -380,8 +381,14 @@ public final class PqAnchorProducer { falconIndices.add(f.getValidatorIndex()); } final HybridSealSupport hybrid = HybridSealSupport.instance(); + // AIP-22 stage 2: only the schemes THIS anchor height demands (per-scheme grids counted from + // the activation height); at a Falcon-only anchor no SLH-DSA seal is gathered or written, which + // is the whole disk saving of the two-grid design final Set schemes = - hybrid.schedule().map(sch -> sch.schemesAt(parentNumber)).orElse(Set.of()); + hybrid + .schedule() + .map(sch -> sch.schemesRequiredForAnchor(blockNumber, cfg.anchorBlock())) + .orElse(Set.of()); final List heard = PqSealCache.instance().extrasFor(parentNumber, parentHeader.getHash()); for (final String schemeId : schemes) { @@ -397,7 +404,9 @@ public final class PqAnchorProducer { "the scheme schedule names '" + schemeId + "', which this binary does not implement"); } final Optional registry = hybrid.registry(); - if (registry.isEmpty()) { + // AERE AIP-22 stage 3b: from H_k the keys come from the v3 epoch in force at the parent + final boolean v3Keys = AerePqIdentity.keysFromV3(parentNumber); + if (registry.isEmpty() && !v3Keys) { throw new PqAnchorNotReadyException( parentNumber, 0, @@ -414,7 +423,10 @@ public final class PqAnchorProducer { rejected++; continue; } - final Optional pk = registry.get().publicKey(s.getValidatorIndex(), schemeId); + final Optional pk = + v3Keys + ? AerePqIdentity.v3KeyAt(parentNumber, s.getValidatorIndex(), schemeId) + : registry.get().publicKey(s.getValidatorIndex(), schemeId); if (pk.isEmpty() || !scheme.get().verifyRaw(pk.get(), message.toArray(), s.getSignature().toArray())) { rejected++; diff --git a/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/tools/PqRegistryV3Tool.java b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/tools/PqRegistryV3Tool.java new file mode 100644 index 0000000..ec81e7d --- /dev/null +++ b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/tools/PqRegistryV3Tool.java @@ -0,0 +1,288 @@ +/* + * AERE AIP-22 stage 3: the tool that makes a registry v3 epoch FROM THE KEYS ALREADY ON THE VALIDATORS. + * + * Two commands, and the split is the security property: + * + * row runs ON THE HOST that holds a validator's keys. It reads that validator's Falcon-512 key + * file (the same "index, f, g, F, pk" properties the node loads through aere.falcon.key), + * its SLH-DSA-SHA2-128s key file (the same "index, sk" the hybrid loader reads) and its ECDSA + * validator key (a file holding the 32-byte hex), and prints ONE row: the id derived from the + * Falcon key, both public keys in their standard encodings, a proof of possession by each, and + * the ecdsa link signed by the validator key. It prints no private key, nor any part of one. + * assemble runs anywhere (the laptop): it takes the rows, builds the manifest, LOADS it through the + * strict loader (every proof and claim is verified again) and prints the manifest and its + * registry hash - the value the chain pins. + * + * Nothing leaves a host but signatures and public keys, which is what "no new key material and nothing + * out of the vault" means for this ceremony (AIP-22 section 3.2, the plan's stage 3). + * + * java -cp '/*' org.hyperledger.besu.consensus.common.bft.tools.PqRegistryV3Tool row \ + * --chain-id 28001 --bind H --count N --index i --falcon-key F --slh-key S --slh-pub HEX \ + * --ecdsa-key-file K [--transport] + * java -cp '/*' ...PqRegistryV3Tool assemble --chain-id 28001 --bind H --count N \ + * --required falcon-512,slh-dsa-sha2-128s row0.json row1.json ... + */ +package org.hyperledger.besu.consensus.common.bft.tools; + +import org.hyperledger.besu.consensus.common.bft.FalconSealScheme; +import org.hyperledger.besu.consensus.common.bft.PqRegistryV3; +import org.hyperledger.besu.consensus.common.bft.SealScheme; +import org.hyperledger.besu.consensus.common.bft.SealSchemes; +import org.hyperledger.besu.crypto.KeyPair; +import org.hyperledger.besu.crypto.SECPPrivateKey; +import org.hyperledger.besu.crypto.SignatureAlgorithm; +import org.hyperledger.besu.crypto.SignatureAlgorithmFactory; +import org.hyperledger.besu.ethereum.core.Util; + +import java.io.IOException; +import java.io.StringReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Properties; +import java.util.TreeMap; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.apache.tuweni.bytes.Bytes; +import org.apache.tuweni.bytes.Bytes32; +import org.bouncycastle.pqc.crypto.falcon.FalconParameters; +import org.bouncycastle.pqc.crypto.falcon.FalconPrivateKeyParameters; + +/** The registry v3 ceremony tool. */ +public final class PqRegistryV3Tool { + + private static final ObjectMapper M = new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT); + + private PqRegistryV3Tool() {} + + /** + * Entry point. + * + * @param args the command and its options + * @throws IOException on unreadable input + */ + public static void main(final String[] args) throws IOException { + if (args.length == 0) { + usage(); + System.exit(2); + } + final Map o = new HashMap<>(); + final List files = new ArrayList<>(); + for (int i = 1; i < args.length; i++) { + if (args[i].startsWith("--")) { + if (args[i].equals("--transport")) { + o.put("transport", "1"); + } else if (i + 1 < args.length) { + o.put(args[i].substring(2), args[++i]); + } else { + usage(); + System.exit(2); + } + } else { + files.add(args[i]); + } + } + switch (args[0]) { + case "keygen" -> keygen(o); + case "row" -> row(o); + case "assemble" -> assemble(o, files); + default -> { + usage(); + System.exit(2); + } + } + } + + /** + * AIP-22 stage 3b: a NEW key pair for a validator, generated ON THE HOST that will hold it (a rotation). Writes the Falcon-512 + * key file (index, f, g, F, pk - the format aere.falcon.key and aere.falcon.keys.next read) and the SLH-DSA-SHA2-128s key file + * (index, alg, sk, pk - the format the hybrid loaders read), owner-only, refusing to overwrite anything; prints only the public + * keys. + */ + private static void keygen(final Map o) throws IOException { + final int index = Integer.parseInt(need(o, "index")); + final Path fOut = Path.of(need(o, "out-falcon")); + final Path sOut = Path.of(need(o, "out-slh")); + if (Files.exists(fOut) || Files.exists(sOut)) { + throw new IllegalStateException("refusing to overwrite an existing key file"); + } + final java.security.SecureRandom rng = + org.hyperledger.besu.crypto.SecureRandomProvider.createSecureRandom(); + final org.bouncycastle.pqc.crypto.falcon.FalconKeyPairGenerator g = + new org.bouncycastle.pqc.crypto.falcon.FalconKeyPairGenerator(); + g.init(new org.bouncycastle.pqc.crypto.falcon.FalconKeyGenerationParameters(rng, FalconParameters.falcon_512)); + final FalconPrivateKeyParameters fk = (FalconPrivateKeyParameters) g.generateKeyPair().getPrivate(); + final SealScheme slh = SealSchemes.SLH_DSA_128S; + final SealScheme.GeneratedPair sp = slh.generate(rng); + final byte[] slhSk = slh.serializePrivateKey(sp.privateKey()).orElseThrow(); + final String falconFile = + "index=" + index + "\nf=" + hx(fk.getSpolyf()).substring(2) + "\ng=" + hx(fk.getG()).substring(2) + + "\nF=" + hx(fk.getSpolyF()).substring(2) + "\npk=" + hx(fk.getPublicKey()).substring(2) + "\n"; + final String slhFile = + "index=" + index + "\nalg=slh-dsa-sha2-128s\nsk=" + hx(slhSk).substring(2) + "\npk=" + + hx(sp.publicRegistryForm()).substring(2) + "\n"; + final java.nio.file.attribute.FileAttribute ownerOnly = + java.nio.file.attribute.PosixFilePermissions.asFileAttribute( + java.nio.file.attribute.PosixFilePermissions.fromString("rw-------")); + Files.writeString(Files.createFile(fOut, ownerOnly), falconFile, StandardCharsets.UTF_8); + Files.writeString(Files.createFile(sOut, ownerOnly), slhFile, StandardCharsets.UTF_8); + final ObjectNode out = M.createObjectNode(); + out.put("index", index); + out.put("falconPk", hx(fk.getPublicKey())); + out.put("slhPub", hx(sp.publicRegistryForm())); + System.out.println(M.writeValueAsString(out)); + } + + private static void row(final Map o) throws IOException { + final long chain = Long.parseLong(need(o, "chain-id")); + final long bind = Long.parseLong(need(o, "bind")); + final int count = Integer.parseInt(need(o, "count")); + final int index = Integer.parseInt(need(o, "index")); + // Falcon: the node's own key file; the index inside must be the index asked for + final Properties fp = new Properties(); + fp.load(new StringReader(Files.readString(Path.of(need(o, "falcon-key")), StandardCharsets.UTF_8))); + if (Integer.parseInt(fp.getProperty("index").trim()) != index) { + throw new IllegalArgumentException("the Falcon key file is for index " + fp.getProperty("index")); + } + final byte[] h = hex(fp.getProperty("pk")); + final FalconPrivateKeyParameters fsk = + new FalconPrivateKeyParameters( + FalconParameters.falcon_512, hex(fp.getProperty("f")), hex(fp.getProperty("g")), + hex(fp.getProperty("F")), h); + final byte[] falconStd = new byte[PqRegistryV3.FALCON_512_PK_LENGTH]; + falconStd[0] = PqRegistryV3.FALCON_512_PK_HEADER; + System.arraycopy(h, 0, falconStd, 1, h.length); + // AIP-22 stage 3b: a ROTATED row keeps the id of its first registration (--id); a new row's id is its Falcon key's derivation + final byte[] id = o.containsKey("id") ? hex(o.get("id")) : PqRegistryV3.idOf(falconStd); + final byte[] falconPop = + falconSign(fsk, PqRegistryV3.popMessage(chain, bind, count, index, id, SealSchemes.FALCON_512.wireId(), falconStd)); + // SLH-DSA: the hybrid key file; the public key is given (it is the registry's), and the private key + // must sign a probe that verifies under it before anything is printed + final Properties sp = new Properties(); + sp.load(new StringReader(Files.readString(Path.of(need(o, "slh-key")), StandardCharsets.UTF_8))); + if (Integer.parseInt(sp.getProperty("index").trim()) != index) { + throw new IllegalArgumentException("the SLH-DSA key file is for index " + sp.getProperty("index")); + } + final SealScheme slh = SealSchemes.SLH_DSA_128S; + final SealScheme.PrivateHandle ssk = + slh.parsePrivateKey(hex(sp.getProperty("sk"))).orElseThrow( + () -> new IllegalArgumentException("the SLH-DSA key does not parse")); + final byte[] slhPub = hex(need(o, "slh-pub")); + final byte[] slhPop = + slh.sign(ssk, PqRegistryV3.popMessage(chain, bind, count, index, id, slh.wireId(), slhPub).toArrayUnsafe()) + .orElseThrow(); + if (!slh.verifyRaw(slhPub, PqRegistryV3.popMessage(chain, bind, count, index, id, slh.wireId(), slhPub).toArrayUnsafe(), slhPop)) { + throw new IllegalStateException("the SLH-DSA key file does not match the public key given; refusing"); + } + // the ECDSA validator key: signs the link from its address to the row + final SignatureAlgorithm ec = SignatureAlgorithmFactory.getInstance(); + final String ecHex = Files.readString(Path.of(need(o, "ecdsa-key-file")), StandardCharsets.UTF_8).trim(); + final SECPPrivateKey ecsk = ec.createPrivateKey(Bytes32.fromHexString(ecHex)); + final KeyPair kp = ec.createKeyPair(ecsk); + final byte[] addr = Util.publicKeyToAddress(kp.getPublicKey()).getBytes().toArrayUnsafe(); + final TreeMap keys = new TreeMap<>(); + keys.put(SealSchemes.FALCON_512.wireId() & 0xff, new PqRegistryV3.Key(SealSchemes.FALCON_512, falconStd, falconPop)); + keys.put(slh.wireId() & 0xff, new PqRegistryV3.Key(slh, slhPub, slhPop)); + final byte[] transport = o.containsKey("transport") ? addr : null; + final Bytes32 rh = + PqRegistryV3.rowHash(chain, bind, count, new PqRegistryV3.Row(index, id, keys, transport, Map.of(), null, null)); + final byte[] claim = ec.sign(PqRegistryV3.claimDigest(chain, rh), kp).encodedBytes().toArrayUnsafe(); + final ObjectNode row = M.createObjectNode(); + row.put("id", hx(id)); + row.putObject("keys").put("falcon-512", hx(falconStd)).put("slh-dsa-sha2-128s", hx(slhPub)); + row.putObject("pop").put("falcon-512", hx(falconPop)).put("slh-dsa-sha2-128s", hx(slhPop)); + if (transport != null) { + row.putObject("transport").put("secp256k1Address", hx(transport)); + } + row.putObject("ecdsa").put("addr", hx(addr)).put("claim", hx(claim)); + // AIP-22 stage 3b: a rotation - EACH old key of the row signs the new row's hash (AERE-PQ-ROTATE-3); the loader requires + // one signature per scheme the previous epoch bound, by exactly that previous key + if (o.containsKey("old-falcon-key") || o.containsKey("old-slh-key")) { + final Bytes32 rot = PqRegistryV3.rotateMessage(rh); + final Properties of = new Properties(); + of.load(new StringReader(Files.readString(Path.of(need(o, "old-falcon-key")), StandardCharsets.UTF_8))); + final FalconPrivateKeyParameters oldF = + new FalconPrivateKeyParameters( + FalconParameters.falcon_512, hex(of.getProperty("f")), hex(of.getProperty("g")), + hex(of.getProperty("F")), hex(of.getProperty("pk"))); + final Properties os = new Properties(); + os.load(new StringReader(Files.readString(Path.of(need(o, "old-slh-key")), StandardCharsets.UTF_8))); + final SealScheme.PrivateHandle oldS = + slh.parsePrivateKey(hex(os.getProperty("sk"))).orElseThrow( + () -> new IllegalArgumentException("the old SLH-DSA key does not parse")); + row.putObject("rotation") + .put("falcon-512", hx(falconSign(oldF, rot))) + .put("slh-dsa-sha2-128s", hx(slh.sign(oldS, rot.toArrayUnsafe()).orElseThrow())); + } + final ObjectNode out = M.createObjectNode(); + out.put("index", index); + out.set("row", row); + System.out.println(M.writeValueAsString(out)); + } + + private static void assemble(final Map o, final List files) throws IOException { + final long chain = Long.parseLong(need(o, "chain-id")); + final long bind = Long.parseLong(need(o, "bind")); + final int count = Integer.parseInt(need(o, "count")); + final List required = Arrays.asList(o.getOrDefault("required", "falcon-512").split(",")); + final ObjectNode m = M.createObjectNode(); + m.put("formatVersion", PqRegistryV3.FORMAT_VERSION); + m.put("chainId", chain); + m.put("bindHeight", bind); + m.put("count", count); + for (final String f : files) { + final JsonNode r = M.readTree(Files.readString(Path.of(f), StandardCharsets.UTF_8)); + m.set(Integer.toString(r.get("index").asInt()), r.get("row")); + } + // AIP-22 stage 3b: an epoch after the switch epoch is loaded AGAINST the one before it (--previous), so every rotation it + // carries is verified; the switch epoch itself (no --previous) must carry every ecdsa link + PqRegistryV3.Registry previous = null; + if (o.containsKey("previous")) { + final JsonNode pj = M.readTree(Files.readString(Path.of(o.get("previous")), StandardCharsets.UTF_8)); + previous = PqRegistryV3.load(pj, chain, required, null, true); + } + final PqRegistryV3.Registry reg = PqRegistryV3.load(m, chain, required, previous, previous == null); + m.put("registryHash", reg.hash().toHexString()); + System.out.println(M.writeValueAsString(m)); + System.err.println("registryHash " + reg.hash().toHexString() + " (" + count + " rows, bound at " + bind + ")"); + } + + private static byte[] falconSign(final FalconPrivateKeyParameters sk, final Bytes32 message) { + return ((FalconSealScheme) SealSchemes.FALCON_512) + .signWithParams(sk, message.toArrayUnsafe()) + .orElseThrow(() -> new IllegalStateException("the Falcon key did not sign")); + } + + private static String need(final Map o, final String k) { + final String v = o.get(k); + if (v == null) { + throw new IllegalArgumentException("missing --" + k); + } + return v; + } + + private static byte[] hex(final String s) { + return Bytes.fromHexStringLenient(s.trim()).toArrayUnsafe(); + } + + private static String hx(final byte[] b) { + final StringBuilder sb = new StringBuilder("0x"); + for (final byte x : b) { + sb.append(String.format(Locale.ROOT, "%02x", x & 0xff)); + } + return sb.toString(); + } + + private static void usage() { + System.err.println("usage: PqRegistryV3Tool row|assemble ... (see the class comment)"); + } +} diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/AereKeysFromV3Test.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/AereKeysFromV3Test.java new file mode 100644 index 0000000..ba91cd1 --- /dev/null +++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/AereKeysFromV3Test.java @@ -0,0 +1,192 @@ +/* + * AERE AIP-22 stage 3b: probes of the keys that seal coming from the v3 epoch in force (AerePqIdentity + * keysFromV3 / v3KeyAt / v3IdAt / verifyKeyContinuity) and of the ecdsa link inherited by id, on the two + * shared registry v3 conformance vectors: ok-epoch-a (bound at 1000, three rows, each with its ecdsa link) + * and ok-epoch-b-rotation (bound at 2000: index 1 rotates both keys under its id, the id of A's index 2 + * moves to index 0, A's index 0 leaves, a new id joins at index 2; no ecdsa links). + * + * What must hold, each with its negative: H_k falls on an epoch boundary at or above H_id or the node + * refuses to arm; below H_k nothing comes from v3; from H_k the key of an index is the key of THAT index in + * the epoch in force (Falcon without its 0x09 header, SLH-DSA as its 32 bytes), so the rotated key replaces + * the old one exactly at the rotation epoch and an index the epoch does not have gives NO key; the + * continuity guard accepts the same keys and refuses one changed byte or a missing key; and an author keeps + * its id across an epoch that omits the ecdsa links, but a removed id's address authors as nothing. + */ +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.datatypes.Address; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.TreeMap; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.tuweni.bytes.Bytes; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +public class AereKeysFromV3Test { + + private static final List SCHEMES = List.of("falcon-512", "slh-dsa-sha2-128s"); + private PqRegistryV3.Registry a; + private PqRegistryV3.Registry b; + private TreeMap epochs; + + private JsonNode vector(final String name) throws IOException { + try (InputStream in = getClass().getResourceAsStream("/aere/registry-v3/" + name + ".json")) { + assertThat(in).isNotNull(); + return new ObjectMapper().readTree(in); + } + } + + @BeforeEach + public void load() throws IOException { + final JsonNode va = vector("ok-epoch-a"); + final JsonNode vb = vector("ok-epoch-b-rotation"); + a = PqRegistryV3.load(va.get("manifest"), va.get("chainId").asLong(), SCHEMES, null, true); + b = PqRegistryV3.load(vb.get("manifest"), vb.get("chainId").asLong(), SCHEMES, a, false); + assertThat(a.bindHeight()).isEqualTo(1000L); + assertThat(b.bindHeight()).isEqualTo(2000L); + epochs = new TreeMap<>(); + epochs.put(a.bindHeight(), a); + epochs.put(b.bindHeight(), b); + AerePqIdentity.uninstall(); + } + + @AfterEach + public void disarm() { + AerePqIdentity.uninstall(); + } + + private static byte[] key(final PqRegistryV3.Registry r, final int index, final String scheme) { + return r.rows().get(index).key(scheme).orElseThrow().publicKey(); + } + + private static Address id(final PqRegistryV3.Registry r, final int index) { + return Address.wrap(Bytes.wrap(r.rows().get(index).id())); + } + + private static Address ecdsa(final PqRegistryV3.Registry r, final int index) { + return Address.wrap(Bytes.wrap(r.rows().get(index).ecdsaAddress())); + } + + @Test + public void keysFromMustBeAnEpochBoundaryAtOrAboveTheIdentityHeight() { + assertThatThrownBy(() -> AerePqIdentity.install(new AerePqIdentity.Config(1000, epochs, 1999))) + .isInstanceOf(AerePqIdentity.IdentityException.class) + .hasMessageContaining("must be the bindHeight of a v3 epoch"); + assertThatThrownBy(() -> AerePqIdentity.install(new AerePqIdentity.Config(2000, epochs, 1000))) + .isInstanceOf(AerePqIdentity.IdentityException.class); + AerePqIdentity.install(new AerePqIdentity.Config(1000, epochs, 2000)); + assertThat(AerePqIdentity.installed().orElseThrow().keysFrom()).isEqualTo(2000L); + } + + @Test + public void belowHkNothingComesFromV3AndStage3aNeverDoes() { + AerePqIdentity.install(new AerePqIdentity.Config(1000, epochs, 2000)); + assertThat(AerePqIdentity.keysFromV3(1999)).isFalse(); + assertThat(AerePqIdentity.keysFromV3(2000)).isTrue(); + AerePqIdentity.install(new AerePqIdentity.Config(1000, epochs)); + assertThat(AerePqIdentity.keysFromV3(Long.MAX_VALUE - 1)).isFalse(); + AerePqIdentity.uninstall(); + assertThat(AerePqIdentity.keysFromV3(5000)).isFalse(); + } + + @Test + public void theKeyIsThatIndexOfTheEpochInForceAndTheRotationTakesOverAtItsEpoch() { + AerePqIdentity.install(new AerePqIdentity.Config(1000, epochs, 1000)); + final byte[] fa1 = AerePqIdentity.v3KeyAt(1999, 1, "falcon-512").orElseThrow(); + final byte[] fb1 = AerePqIdentity.v3KeyAt(2000, 1, "falcon-512").orElseThrow(); + // the registry form: 896 bytes, the 0x09 header dropped + assertThat(fa1).hasSize(896); + assertThat(fa1).isEqualTo(Arrays.copyOfRange(key(a, 1, "falcon-512"), 1, 897)); + assertThat(fb1).isEqualTo(Arrays.copyOfRange(key(b, 1, "falcon-512"), 1, 897)); + assertThat(fb1).isNotEqualTo(fa1); + final byte[] sa1 = AerePqIdentity.v3KeyAt(1999, 1, "slh-dsa-sha2-128s").orElseThrow(); + assertThat(sa1).hasSize(32).isEqualTo(key(a, 1, "slh-dsa-sha2-128s")); + assertThat(AerePqIdentity.v3KeyAt(2000, 1, "slh-dsa-sha2-128s").orElseThrow()) + .isEqualTo(key(b, 1, "slh-dsa-sha2-128s")) + .isNotEqualTo(sa1); + // indices belong to the epoch: at 2000 index 0 is the validator that was index 2 in epoch A + assertThat(AerePqIdentity.v3KeyAt(2000, 0, "falcon-512").orElseThrow()) + .isEqualTo(Arrays.copyOfRange(key(a, 2, "falcon-512"), 1, 897)); + assertThat(AerePqIdentity.v3IdAt(2000, 0)).contains(id(a, 2)); + assertThat(AerePqIdentity.v3IdAt(2000, 2)).contains(id(b, 2)); + // an index the epoch does not have: NO key (fail closed), never a fall-back + assertThat(AerePqIdentity.v3KeyAt(2000, 3, "falcon-512")).isEmpty(); + assertThat(AerePqIdentity.v3KeyAt(2000, -1, "falcon-512")).isEmpty(); + assertThat(AerePqIdentity.v3IdAt(2000, 3)).isEmpty(); + } + + @Test + public void theContinuityGuardAcceptsTheSameKeysAndRefusesAnyOther() { + AerePqIdentity.install(new AerePqIdentity.Config(1000, epochs, 1000)); + // the existing registries give exactly the keys of epoch A: accepted + AerePqIdentity.verifyKeyContinuity( + (i, s) -> AerePqIdentity.v3KeyAt(1000, i, s).map(byte[]::clone)); + // one byte of one SLH-DSA key differs: refused, naming the row and the scheme + assertThatThrownBy( + () -> + AerePqIdentity.verifyKeyContinuity( + (i, s) -> { + final byte[] k = AerePqIdentity.v3KeyAt(1000, i, s).orElseThrow().clone(); + if (i == 2 && s.equals("slh-dsa-sha2-128s")) { + k[5] ^= 1; + } + return Optional.of(k); + })) + .isInstanceOf(AerePqIdentity.IdentityException.class) + .hasMessageContaining("row 2 slh-dsa-sha2-128s differs"); + // a key the existing registries cannot produce: refused + assertThatThrownBy( + () -> + AerePqIdentity.verifyKeyContinuity( + (i, s) -> i == 0 ? Optional.empty() : AerePqIdentity.v3KeyAt(1000, i, s))) + .isInstanceOf(AerePqIdentity.IdentityException.class) + .hasMessageContaining("row 0"); + // no H_k configured: the guard is inert even with a function that has no keys at all + AerePqIdentity.install(new AerePqIdentity.Config(1000, epochs)); + AerePqIdentity.verifyKeyContinuity((i, s) -> Optional.empty()); + } + + @Test + public void theFalconSealSupportDoorsTakeTheirKeyAndIdFromV3FromHk() { + // the WIRING, not only the helper: FalconSealSupport.keyAt (behind every Falcon verification) and the index -> identity doors + // must answer from the v3 epoch in force from H_k. This node has no registry schedule at all, so any answer from H_k on can + // only come from v3; below H_k the doors fall back to the (absent) registries and answer nothing. + AerePqIdentity.install(new AerePqIdentity.Config(1000, epochs, 2000)); + final FalconSealSupport fs = FalconSealSupport.instance(); + assertThat(fs.publicKeyBytesAtHistoric(2000, 1)) + .contains(Arrays.copyOfRange(key(b, 1, "falcon-512"), 1, 897)); + assertThat(fs.publicKeyBytesAtHistoric(2000, 0)) + .contains(Arrays.copyOfRange(key(a, 2, "falcon-512"), 1, 897)); + assertThat(fs.publicKeyBytesAtHistoric(2000, 3)).isEmpty(); + assertThat(fs.addressForIndexAtHistoric(2000, 2)).isEqualTo(id(b, 2)); + assertThat(fs.addressForIndexAtOwnHead(2000, 0)).isEqualTo(id(a, 2)); + assertThat(fs.addressForIndexAtHistoric(2000, 3)).isNull(); + // below H_k: not from v3 (a v3 answer here would be the rotation taking effect early) + assertThat(fs.publicKeyBytesAtHistoric(1999, 1)) + .isNotEqualTo(Optional.of(Arrays.copyOfRange(key(a, 1, "falcon-512"), 1, 897))); + } + + @Test + public void anAuthorKeepsItsIdAcrossAnEpochWithoutLinksAndARemovedIdAuthorsNothing() { + AerePqIdentity.install(new AerePqIdentity.Config(1000, epochs, 1000)); + // epoch B carries no ecdsa links; the id of A's index 1 is still registered, so its address keeps it + assertThat(b.rows().get(1).ecdsaAddress()).isNull(); + assertThat(AerePqIdentity.author(ecdsa(a, 1), 1999)).isEqualTo(id(a, 1)); + assertThat(AerePqIdentity.author(ecdsa(a, 1), 2000)).isEqualTo(id(a, 1)); + assertThat(AerePqIdentity.author(ecdsa(a, 2), 2500)).isEqualTo(id(a, 2)); + // A's index 0 left in B: its address is itself again, which is not in the set + assertThat(AerePqIdentity.author(ecdsa(a, 0), 1999)).isEqualTo(id(a, 0)); + assertThat(AerePqIdentity.author(ecdsa(a, 0), 2000)).isEqualTo(ecdsa(a, 0)); + } +} diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/AereLocalKeysV3Test.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/AereLocalKeysV3Test.java new file mode 100644 index 0000000..e67a5bf --- /dev/null +++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/AereLocalKeysV3Test.java @@ -0,0 +1,164 @@ +/* + * AERE AIP-22 stage 3b-ii: probes of the LOCAL keys by height - a node signs, from H_k, with the key of its own row in the v3 + * epoch in force and at that row's index - on two epochs built here with PROBA keys this test holds (the shared vectors carry no + * private keys): epoch A bound at 1000 (v0, v1, v2 at indices 0, 1, 2, the switch epoch) and epoch B bound at 2000 (v2 MOVED to + * index 0, v1 ROTATED both keys under its id at index 1 with the rotation signed by its old keys, v0 left, a new v3 at index 2). + * + * What must hold, each with its negative: a node finds its row by its key and the index moves with the epoch (a key that no row + * of the epoch carries finds nothing); the hybrid producer of a rotating node signs with its OLD key up to the rotation epoch and + * with its NEXT key from it, at the right index, each seal verifying under exactly one of the two keys; a moved node signs at its + * new index; and a node that lacks its rotated key produces nothing from the rotation epoch (the fact the startup guard refuses on). + */ +package org.hyperledger.besu.consensus.common.bft; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.TreeMap; +import java.util.function.LongToIntFunction; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.apache.tuweni.bytes.Bytes; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +public class AereLocalKeysV3Test { + + private static final List SCHEMES = List.of("falcon-512", "slh-dsa-sha2-128s"); + private static final String SLH = "slh-dsa-sha2-128s"; + private PqRegistryV3Test.Proba v0; + private PqRegistryV3Test.Proba v1; + private PqRegistryV3Test.Proba v2; + private PqRegistryV3Test.Proba v1b; + private PqRegistryV3Test.Proba v3; + + @BeforeEach + public void build() { + v0 = PqRegistryV3Test.proba(); + v1 = PqRegistryV3Test.proba(); + v2 = PqRegistryV3Test.proba(); + v1b = PqRegistryV3Test.proba(); + v3 = PqRegistryV3Test.proba(); + final var d0 = new PqRegistryV3Test.Draft(PqRegistryV3.idOf(v0.falconStd()), PqRegistryV3Test.both(v0), v0); + final var d1 = new PqRegistryV3Test.Draft(PqRegistryV3.idOf(v1.falconStd()), PqRegistryV3Test.both(v1), v1); + final var d2 = new PqRegistryV3Test.Draft(PqRegistryV3.idOf(v2.falconStd()), PqRegistryV3Test.both(v2), v2); + final ObjectNode ma = + PqRegistryV3Test.manifest( + 1000, + List.of( + PqRegistryV3Test.rowJson(1000, 3, 0, d0, true, true), + PqRegistryV3Test.rowJson(1000, 3, 1, d1, true, false), + PqRegistryV3Test.rowJson(1000, 3, 2, d2, true, false))); + final PqRegistryV3.Registry a = PqRegistryV3.load(ma, PqRegistryV3Test.CHAIN, SCHEMES, null, true); + final var d1b = new PqRegistryV3Test.Draft(d1.id(), PqRegistryV3Test.both(v1b), v1); + final var d3 = new PqRegistryV3Test.Draft(PqRegistryV3.idOf(v3.falconStd()), PqRegistryV3Test.both(v3), v3); + final ObjectNode r1b = PqRegistryV3Test.rowJson(2000, 3, 1, d1b, false, false); + PqRegistryV3Test.rotate(r1b, 2000, 3, 1, List.of(v1.falcon(), v1.slh())); + final ObjectNode mb = + PqRegistryV3Test.manifest( + 2000, + List.of( + PqRegistryV3Test.rowJson(2000, 3, 0, d2, false, false), + r1b, + PqRegistryV3Test.rowJson(2000, 3, 2, d3, false, false))); + final PqRegistryV3.Registry b = PqRegistryV3.load(mb, PqRegistryV3Test.CHAIN, SCHEMES, a, false); + final TreeMap epochs = new TreeMap<>(); + epochs.put(1000L, a); + epochs.put(2000L, b); + AerePqIdentity.uninstall(); + AerePqIdentity.install(new AerePqIdentity.Config(1000, epochs, 1000)); + } + + @AfterEach + public void disarm() { + AerePqIdentity.uninstall(); + } + + private static byte[] falcon(final PqRegistryV3Test.Proba p) { + return p.falcon().publicRegistryForm(); + } + + /** The local index a node holding these Falcon keys signs as at a height (what FalconSealSupport.localAt resolves). */ + private static LongToIntFunction indexOf(final PqRegistryV3Test.Proba... keys) { + return h -> { + for (final PqRegistryV3Test.Proba k : keys) { + final Optional i = AerePqIdentity.v3IndexOfKey(h, "falcon-512", falcon(k)); + if (i.isPresent()) { + return i.get(); + } + } + return -1; + }; + } + + private static HybridSealProducer producer( + final PqRegistryV3Test.Proba base, final List next, final LongToIntFunction idx) { + return new HybridSealProducer( + 0L, + PqSchemeSchedule.parse("0:falcon-512+" + SLH), + -1, + Map.of(SLH, base.slh().privateKey()), + 0L, + next.isEmpty() ? Map.of() : Map.of(SLH, next.stream().map(p -> p.slh().privateKey()).toList()), + idx); + } + + private static boolean verifies(final SchemeSeal s, final PqRegistryV3Test.Proba key, final Bytes msg) { + return SealSchemes.SLH_DSA_128S.verifyRaw( + key.slh().publicRegistryForm(), msg.toArrayUnsafe(), s.getSignature().toArrayUnsafe()); + } + + @Test + public void aNodeFindsItsRowByItsKeyAndTheIndexMovesWithTheEpoch() { + assertThat(AerePqIdentity.v3IndexOfKey(1999, "falcon-512", falcon(v2))).contains(2); + assertThat(AerePqIdentity.v3IndexOfKey(2000, "falcon-512", falcon(v2))).contains(0); + assertThat(AerePqIdentity.v3IndexOfKey(1999, "falcon-512", falcon(v1))).contains(1); + assertThat(AerePqIdentity.v3IndexOfKey(2000, "falcon-512", falcon(v1))).isEmpty(); + assertThat(AerePqIdentity.v3IndexOfKey(2000, "falcon-512", falcon(v1b))).contains(1); + assertThat(AerePqIdentity.v3IndexOfKey(1999, "falcon-512", falcon(v1b))).isEmpty(); + assertThat(AerePqIdentity.v3IndexOfKey(2000, "falcon-512", falcon(v0))).isEmpty(); + } + + @Test + public void aRotatingNodeSignsWithItsOldKeyUpToTheRotationEpochAndItsNextKeyFromIt() { + final HybridSealProducer p = producer(v1, List.of(v1b), indexOf(v1, v1b)); + final Bytes msg = Bytes.wrap("AERE-TEST-COMMIT".getBytes(java.nio.charset.StandardCharsets.UTF_8)); + final List before = p.sealsFor(1999, msg); + assertThat(before).hasSize(1); + assertThat(before.get(0).getValidatorIndex()).isEqualTo(1); + assertThat(verifies(before.get(0), v1, msg)).isTrue(); + assertThat(verifies(before.get(0), v1b, msg)).isFalse(); + final List after = p.sealsFor(2000, msg); + assertThat(after).hasSize(1); + assertThat(after.get(0).getValidatorIndex()).isEqualTo(1); + assertThat(verifies(after.get(0), v1b, msg)).isTrue(); + assertThat(verifies(after.get(0), v1, msg)).isFalse(); + } + + @Test + public void aMovedNodeSignsAtItsNewIndex() { + final HybridSealProducer p = producer(v2, List.of(), indexOf(v2)); + final Bytes msg = Bytes.wrap("AERE-TEST-COMMIT".getBytes(java.nio.charset.StandardCharsets.UTF_8)); + assertThat(p.sealsFor(1999, msg).get(0).getValidatorIndex()).isEqualTo(2); + final List after = p.sealsFor(2000, msg); + assertThat(after.get(0).getValidatorIndex()).isEqualTo(0); + assertThat(verifies(after.get(0), v2, msg)).isTrue(); + } + + @Test + public void aNodeWithoutItsRotatedKeyProducesNothingFromTheRotationEpoch() { + final HybridSealProducer p = producer(v1, List.of(), indexOf(v1, v1b)); + final Bytes msg = Bytes.wrap("AERE-TEST-COMMIT".getBytes(java.nio.charset.StandardCharsets.UTF_8)); + assertThat(p.sealsFor(1999, msg)).hasSize(1); + // its Falcon index is resolvable only through v1b, which this node does not hold: nothing, and the guard's basis is empty + final HybridSealProducer onlyOld = producer(v1, List.of(), indexOf(v1)); + assertThat(onlyOld.sealsFor(2000, msg)).isEmpty(); + assertThat(onlyOld.v3KeyAt(SealSchemes.SLH_DSA_128S, 2000)).isEmpty(); + // holding the rotated Falcon key but not the rotated SLH-DSA key: the row's SLH key has no local match either + assertThat(p.v3KeyAt(SealSchemes.SLH_DSA_128S, 2000)).isEmpty(); + assertThat(p.sealsFor(2000, msg)).isEmpty(); + } +} diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/AerePqIdentityTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/AerePqIdentityTest.java new file mode 100644 index 0000000..a375f42 --- /dev/null +++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/AerePqIdentityTest.java @@ -0,0 +1,135 @@ +/* + * AERE AIP-22 stage 3: probes of the identity translation (AerePqIdentity), on the registry v3 + * conformance vector ok-epoch-a (three rows, each with its ecdsa link, row 0 with a transport). + * + * What must hold, each with its negative: disarmed, every function is the identity (the build that + * carries the class and is never configured is byte for byte the old one); armed, an address becomes + * its id exactly from H_id and not one block earlier; an address without a row stays itself (so its + * message is simply not in the set); the set after H_id - 1 becomes the sorted ids and a member + * without a row REFUSES instead of being dropped; the local node is recognised in both forms and only + * its own; ids reach peers through their transport, and an id with no transport means "every peer". + */ +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.datatypes.Address; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import java.util.TreeMap; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.tuweni.bytes.Bytes; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +public class AerePqIdentityTest { + + private static final long H_ID = 1500; + private PqRegistryV3.Registry epoch; + private final List
addr = new ArrayList<>(); + private final List
id = new ArrayList<>(); + + @BeforeEach + public void armOnTheVector() throws IOException { + final JsonNode v; + try (InputStream in = getClass().getResourceAsStream("/aere/registry-v3/ok-epoch-a.json")) { + assertThat(in).isNotNull(); + v = new ObjectMapper().readTree(in); + } + epoch = + PqRegistryV3.load( + v.get("manifest"), v.get("chainId").asLong(), + List.of("falcon-512", "slh-dsa-sha2-128s"), null, true); + for (final PqRegistryV3.Row r : epoch.rows()) { + addr.add(Address.wrap(Bytes.wrap(r.ecdsaAddress()))); + id.add(Address.wrap(Bytes.wrap(r.id()))); + } + final TreeMap epochs = new TreeMap<>(); + epochs.put(epoch.bindHeight(), epoch); + AerePqIdentity.uninstall(); + assertThat(epoch.bindHeight()).isLessThanOrEqualTo(H_ID); + AerePqIdentity.install(new AerePqIdentity.Config(H_ID, epochs)); + } + + @AfterEach + public void disarm() { + AerePqIdentity.uninstall(); + } + + @Test + public void disarmedEverythingIsTheIdentity() { + AerePqIdentity.uninstall(); + assertThat(AerePqIdentity.author(addr.get(0), H_ID + 10)).isEqualTo(addr.get(0)); + final Collection
set = new java.util.TreeSet<>(addr); + assertThat(AerePqIdentity.translateSetAfter(set, H_ID - 1)).isSameAs(set); + assertThat(AerePqIdentity.isLocal(id.get(0), addr.get(0))).isFalse(); + assertThat(AerePqIdentity.transportTargets(addr)).contains(addr); + } + + @Test + public void anAddressBecomesItsIdExactlyFromHid() { + assertThat(AerePqIdentity.author(addr.get(1), H_ID - 1)).isEqualTo(addr.get(1)); + assertThat(AerePqIdentity.author(addr.get(1), H_ID)).isEqualTo(id.get(1)); + assertThat(AerePqIdentity.author(addr.get(2), H_ID + 1000)).isEqualTo(id.get(2)); + // an address the registry does not link stays itself: its message is then not in the set + final Address stranger = Address.fromHexString("0x00000000000000000000000000000000000000aa"); + assertThat(AerePqIdentity.author(stranger, H_ID + 5)).isEqualTo(stranger); + } + + @Test + public void theSetAfterHidMinusOneBecomesTheSortedIdsAndNowhereElse() { + final Collection
set = new java.util.TreeSet<>(addr); + assertThat(AerePqIdentity.translateSetAfter(set, H_ID - 2)).isSameAs(set); + assertThat(AerePqIdentity.translateSetAfter(set, H_ID)).isSameAs(set); + final Collection
t = AerePqIdentity.translateSetAfter(set, H_ID - 1); + assertThat(t).containsExactlyElementsOf(new java.util.TreeSet<>(id)); + } + + @Test + public void aMemberWithoutARowRefusesInsteadOfBeingDropped() { + final Collection
set = new java.util.TreeSet<>(addr); + set.add(Address.fromHexString("0x00000000000000000000000000000000000000bb")); + assertThatThrownBy(() -> AerePqIdentity.translateSetAfter(set, H_ID - 1)) + .isInstanceOf(AerePqIdentity.IdentityException.class) + .hasMessageContaining("has no row"); + } + + @Test + public void theLocalNodeIsRecognisedInBothFormsAndOnlyItsOwn() { + assertThat(AerePqIdentity.isLocal(addr.get(0), addr.get(0))).isTrue(); + assertThat(AerePqIdentity.isLocal(id.get(0), addr.get(0))).isTrue(); + assertThat(AerePqIdentity.isLocal(id.get(1), addr.get(0))).isFalse(); + assertThat(AerePqIdentity.local(addr.get(0), H_ID - 1)).isEqualTo(addr.get(0)); + assertThat(AerePqIdentity.local(addr.get(0), H_ID)).isEqualTo(id.get(0)); + } + + @Test + public void idsReachPeersThroughTheirTransportOrEveryPeer() { + final byte[] t0 = epoch.rows().get(0).transport(); + assertThat(t0).isNotNull(); + assertThat(AerePqIdentity.transportTargets(List.of(id.get(0)))) + .contains(List.of(Address.wrap(Bytes.wrap(t0)))); + assertThat(epoch.rows().get(1).transport()).isNull(); + assertThat(AerePqIdentity.transportTargets(List.of(id.get(0), id.get(1)))).isEqualTo(Optional.empty()); + // below H_id the recipients are addresses and go out as before + assertThat(AerePqIdentity.transportTargets(addr)).contains(addr); + } + + @Test + public void anIdentityHeightWithNoEpochAtOrBelowItRefusesToArm() { + final TreeMap epochs = new TreeMap<>(); + epochs.put(epoch.bindHeight(), epoch); + assertThatThrownBy( + () -> AerePqIdentity.install(new AerePqIdentity.Config(epoch.bindHeight() - 1, epochs))) + .isInstanceOf(AerePqIdentity.IdentityException.class); + } +} 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 index 5569abe..6dfa922 100644 --- 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 @@ -92,6 +92,35 @@ class HybridSealProducerTest { assertThat(seals.get(0).getValidatorIndex()).isEqualTo(INDEX); } + // ------------------------------------------- intervale per schema (AIP-22 treapta 2) + + /** + * With SLH-DSA on the 128 grid and the anchor grids counted from H0 = 64, the producer signs the + * SLH-DSA seal only at the parent of an anchor on that grid; at the parent of a Falcon-only + * anchor (32 grid) it emits nothing, although the key is there and the gate is open. The two + * heights differ in nothing but the grid, which is the only thing this proof is about. + */ + @Test + void theSlhDsaSealIsProducedOnlyAtParentsOfAnchorsOnItsOwnGrid() { + final long h0 = 64L; + final PqSchemeSchedule cuIntervale = + PqSchemeSchedule.parse( + "0:" + SealSchemes.FALCON_512.id() + "/32+" + SealSchemes.SLH_DSA_128S.id() + "/128"); + final HybridSealProducer p = + new HybridSealProducer( + H_ATASARE, cuIntervale, INDEX, Map.of(SealSchemes.SLH_DSA_128S.id(), slh.privateKey()), h0); + final long ancoraHibrida = h0 + 128L * 10; // 1344: pe grila 128 + final long ancoraDoarFalcon = ancoraHibrida + 32L; // 1376: pe grila 32, nu pe 128 + assertThat(p.sealsFor(ancoraHibrida - 1, MESSAGE)).hasSize(1); + assertThat(p.sealsFor(ancoraDoarFalcon - 1, MESSAGE)).isEmpty(); + // the negative control of the grid origin: the same heights read against another origin flip + final HybridSealProducer altaOrigine = + new HybridSealProducer( + H_ATASARE, cuIntervale, INDEX, Map.of(SealSchemes.SLH_DSA_128S.id(), slh.privateKey()), h0 + 32); + assertThat(altaOrigine.sealsFor(ancoraHibrida - 1, MESSAGE)).isEmpty(); + assertThat(altaOrigine.sealsFor(ancoraDoarFalcon - 1, MESSAGE)).hasSize(1); + } + // ---------------------------------------------------------------- jumatatea de certificat @Test diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqRegistryV3Test.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqRegistryV3Test.java new file mode 100644 index 0000000..e5538e1 --- /dev/null +++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqRegistryV3Test.java @@ -0,0 +1,509 @@ +/* + * AERE AIP-22 stage 3: probes of the v3 registry, and the writer of its conformance vectors. + * + * TWO MODES. With the environment variable AERE_REG3_VECTORS_OUT set to a directory, the vector + * writer generates PROBA keys (its helpers are package-visible since 2026-09-23: AereLocalKeysV3Test builds its own epochs with + * private keys it holds) (Falcon-512, SLH-DSA-SHA2-128s, secp256k1), builds two epochs and one + * negative vector per refusal code, and writes them as JSON; each negative differs from a valid + * manifest in ONE field (the lesson of AIP-20: a negative that differs in two places measures which + * check the validator reaches first, not the one it names), and where a field feeds a signed + * message the signatures are re-made over the changed field so that only the named rule can fail. + * Without the variable, every test reads the checked-in vectors from test resources + * (aere/registry-v3/) and requires the outcome each vector names: "ok" with the stated registry hash + * and row hashes, or the exact refusal code. The same files are read by the Nethermind port and by + * the public JS tool, which is what makes them conformance vectors and not just fixtures. + */ +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.KeyPair; +import org.hyperledger.besu.crypto.SecureRandomProvider; +import org.hyperledger.besu.crypto.SignatureAlgorithm; +import org.hyperledger.besu.crypto.SignatureAlgorithmFactory; +import org.hyperledger.besu.ethereum.core.Util; + +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.security.SecureRandom; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.TreeMap; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.apache.tuweni.bytes.Bytes; +import org.apache.tuweni.bytes.Bytes32; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + +public class PqRegistryV3Test { + + private static final ObjectMapper M = + new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT); + private static final String RES = "/aere/registry-v3/"; + private static final List VECTORS = + List.of( + "ok-epoch-a", "ok-epoch-b-rotation", "n01-format", "n02-chain", "n03-extra-field", + "n05-unknown-scheme", "n06-required-scheme", "n07-falcon-header", "n08-pop", + "n09-id-derivation", "n10-duplicate-id", "n11-duplicate-key", "n12-rotation-one-key", + "n12b-rotation-wrong-key", "n13-rotation-unchanged", "n14-ecdsa-wrong-key", + "n14b-ecdsa-missing", "n15-transport", "n16-declared-hash"); + + // ------------------------------------------------------------------------------------------ + // The conformance vectors + // ------------------------------------------------------------------------------------------ + + @Test + public void everyVectorHasTheOutcomeItNames() throws IOException { + int ok = 0; + int refused = 0; + for (final String name : VECTORS) { + final JsonNode v = vector(name); + final PqRegistryV3.Registry prev = + v.hasNonNull("previous") + ? loadVector(vector(v.get("previous").asText()), null) + : null; + final String expect = v.get("expect").asText(); + if ("ok".equals(expect)) { + final PqRegistryV3.Registry r = loadVector(v, prev); + assertThat(r.hash().toHexString()).as(name).isEqualTo(v.get("registryHash").asText()); + final ArrayNode rh = (ArrayNode) v.get("rowHashes"); + assertThat(r.rowHashes()).as(name).hasSize(rh.size()); + for (int i = 0; i < rh.size(); i++) { + assertThat(r.rowHashes().get(i).toHexString()).as(name + " row " + i) + .isEqualTo(rh.get(i).asText()); + } + ok++; + } else { + final PqRegistryV3.Registry prevF = prev; + assertThatThrownBy(() -> loadVector(v, prevF)) + .as(name) + .isInstanceOf(PqRegistryV3.RegistryV3Exception.class) + .hasMessageStartingWith(expect + ":"); + refused++; + } + } + assertThat(ok).isEqualTo(2); + assertThat(refused).isEqualTo(VECTORS.size() - 2); + } + + @Test + public void merkleProofsVerifyForEveryRowAndNotOneRowPastTheEnd() throws IOException { + final JsonNode v = vector("ok-epoch-a"); + final PqRegistryV3.Registry r = loadVector(v, null); + for (int i = 0; i < r.count(); i++) { + final List p = PqRegistryV3.merkleProof(r.rowHashes(), i); + assertThat(PqRegistryV3.verifyMerkleProof(r.rowHashes().get(i), i, r.count(), p, r.root())) + .as("row " + i) + .isTrue(); + } + // three rows: the last leaf is paired with itself, so its path also "proves" index 3 unless the + // count bound refuses it - that refusal is the whole point of the bound + final int last = r.count() - 1; + final List p = PqRegistryV3.merkleProof(r.rowHashes(), last); + assertThat(r.count() % 2).isEqualTo(1); + assertThat( + PqRegistryV3.verifyMerkleProof(r.rowHashes().get(last), last + 1, r.count(), p, r.root())) + .isFalse(); + // and a leaf that is not a row never verifies + assertThat( + PqRegistryV3.verifyMerkleProof( + Bytes32.fromHexStringLenient("0x01"), 0, r.count(), + PqRegistryV3.merkleProof(r.rowHashes(), 0), r.root())) + .isFalse(); + } + + @Test + public void theIdIsTheAip20SenderDerivationOfTheFalconKey() throws IOException { + final JsonNode v = vector("ok-epoch-a"); + final PqRegistryV3.Registry r = loadVector(v, null); + for (final PqRegistryV3.Row row : r.rows()) { + final byte[] pk = row.key("falcon-512").orElseThrow().publicKey(); + assertThat(pk).hasSize(897); + assertThat(pk[0]).isEqualTo((byte) 0x09); + assertThat(PqRegistryV3.idOf(pk)).isEqualTo(row.id()); + } + } + + @Test + public void theRegistryHashCommitsToChainHeightAndCount() throws IOException { + final JsonNode v = vector("ok-epoch-a"); + final PqRegistryV3.Registry r = loadVector(v, null); + final Bytes32 h = PqRegistryV3.registryHash(r.chainId(), r.bindHeight(), r.count(), r.root()); + assertThat(h).isEqualTo(r.hash()); + assertThat(PqRegistryV3.registryHash(r.chainId() + 1, r.bindHeight(), r.count(), r.root())) + .isNotEqualTo(h); + assertThat(PqRegistryV3.registryHash(r.chainId(), r.bindHeight() + 1, r.count(), r.root())) + .isNotEqualTo(h); + assertThat(PqRegistryV3.registryHash(r.chainId(), r.bindHeight(), r.count() + 1, r.root())) + .isNotEqualTo(h); + } + + @Test + public void thePinnedHashIsCompared() throws IOException { + final JsonNode v = vector("ok-epoch-a"); + final Bytes32 good = Bytes32.fromHexString(v.get("registryHash").asText()); + final PqRegistryV3.Registry r = + PqRegistryV3.loadPinned( + v.get("manifest"), v.get("chainId").asLong(), required(v), null, + v.get("ecdsaRequired").asBoolean(), good); + assertThat(r.hash()).isEqualTo(good); + assertThatThrownBy( + () -> + PqRegistryV3.loadPinned( + v.get("manifest"), v.get("chainId").asLong(), required(v), null, + v.get("ecdsaRequired").asBoolean(), Bytes32.ZERO)) + .isInstanceOf(PqRegistryV3.RegistryV3Exception.class) + .hasMessageStartingWith("AERE-PQC-REG3-16:"); + } + + private static PqRegistryV3.Registry loadVector(final JsonNode v, final PqRegistryV3.Registry prev) { + return PqRegistryV3.load( + v.get("manifest"), v.get("chainId").asLong(), required(v), prev, + v.get("ecdsaRequired").asBoolean()); + } + + private static List required(final JsonNode v) { + final List out = new ArrayList<>(); + v.get("requiredSchemes").forEach(n -> out.add(n.asText())); + return out; + } + + private static JsonNode vector(final String name) throws IOException { + try (InputStream in = PqRegistryV3Test.class.getResourceAsStream(RES + name + ".json")) { + assertThat(in).as("vector " + name + " is on the test classpath").isNotNull(); + return M.readTree(in); + } + } + + // ------------------------------------------------------------------------------------------ + // The vector writer (runs only with AERE_REG3_VECTORS_OUT) + // ------------------------------------------------------------------------------------------ + + static final long CHAIN = 28001; + private static final SecureRandom RNG = SecureRandomProvider.createSecureRandom(); + private static final SignatureAlgorithm EC = SignatureAlgorithmFactory.getInstance(); + + /** One validator's PROBA keys. */ + record Proba( + SealScheme.GeneratedPair falcon, SealScheme.GeneratedPair slh, KeyPair ecdsa, byte[] node) { + byte[] falconStd() { + final byte[] h = falcon.publicRegistryForm(); + final byte[] s = new byte[897]; + s[0] = 0x09; + System.arraycopy(h, 0, s, 1, 896); + return s; + } + + byte[] address() { + return Util.publicKeyToAddress(ecdsa.getPublicKey()).getBytes().toArrayUnsafe(); + } + } + + static Proba proba() { + final KeyPair node = EC.generateKeyPair(); + return new Proba( + SealSchemes.FALCON_512.generate(RNG), + SealSchemes.SLH_DSA_128S.generate(RNG), + EC.generateKeyPair(), + Util.publicKeyToAddress(node.getPublicKey()).getBytes().toArrayUnsafe()); + } + + /** A row under construction: keys (scheme id to pair) with the id they are filed under. */ + record Draft(byte[] id, Map keys, Proba owner) {} + + static ObjectNode rowJson( + final long bind, final int count, final int index, final Draft d, final boolean ecdsa, + final boolean transport) { + final ObjectNode row = M.createObjectNode(); + row.put("id", hx(d.id())); + final ObjectNode keys = row.putObject("keys"); + final ObjectNode pop = row.putObject("pop"); + for (final Map.Entry e : d.keys().entrySet()) { + final SealScheme s = SealSchemes.byId(e.getKey()).orElseThrow(); + final byte[] std = standard(s, e.getValue()); + keys.put(e.getKey(), hx(std)); + final Bytes32 msg = PqRegistryV3.popMessage(CHAIN, bind, count, index, d.id(), s.wireId(), std); + pop.put(e.getKey(), hx(s.sign(e.getValue().privateKey(), msg.toArrayUnsafe()).orElseThrow())); + } + if (transport) { + row.putObject("transport").put("secp256k1Address", hx(d.owner().node())); + } + if (ecdsa) { + claim(row, bind, count, index, d.owner().ecdsa()); + } + return row; + } + + /** (Re)computes the ecdsa claim of a row from its current JSON, with the given key. */ + private static void claim( + final ObjectNode row, final long bind, final int count, final int index, final KeyPair kp) { + final Bytes32 rh = rowHashOf(row, bind, count, index); + final Bytes32 d = PqRegistryV3.claimDigest(CHAIN, rh); + final ObjectNode ec = row.putObject("ecdsa"); + ec.put("addr", hx(Util.publicKeyToAddress(kp.getPublicKey()).getBytes().toArrayUnsafe())); + ec.put("claim", hx(EC.sign(d, kp).encodedBytes().toArrayUnsafe())); + } + + /** The row hash of a row as written in JSON (reusing the loader's own parser via a 1-row load + * is impossible here, so the pre-image is rebuilt from the JSON fields). */ + private static Bytes32 rowHashOf(final ObjectNode row, final long bind, final int count, final int index) { + final TreeMap keys = new TreeMap<>(); + final Iterator> it = row.get("keys").fields(); + while (it.hasNext()) { + final Map.Entry e = it.next(); + final SealScheme s = SealSchemes.byId(e.getKey()).orElseThrow(); + keys.put( + s.wireId() & 0xff, + new PqRegistryV3.Key( + s, Bytes.fromHexString(e.getValue().asText()).toArrayUnsafe(), + Bytes.fromHexString(row.get("pop").get(e.getKey()).asText()).toArrayUnsafe())); + } + final byte[] transport = + row.has("transport") + ? Bytes.fromHexString(row.get("transport").get("secp256k1Address").asText()).toArrayUnsafe() + : null; + final PqRegistryV3.Row r = + new PqRegistryV3.Row( + index, Bytes.fromHexString(row.get("id").asText()).toArrayUnsafe(), keys, transport, + Map.of(), null, null); + return PqRegistryV3.rowHash(CHAIN, bind, count, r); + } + + private static byte[] standard(final SealScheme s, final SealScheme.GeneratedPair p) { + if (s == SealSchemes.FALCON_512) { + final byte[] h = p.publicRegistryForm(); + final byte[] std = new byte[897]; + std[0] = 0x09; + System.arraycopy(h, 0, std, 1, 896); + return std; + } + return p.publicRegistryForm(); + } + + static ObjectNode manifest(final long bind, final List rows) { + final ObjectNode m = M.createObjectNode(); + m.put("formatVersion", 3); + m.put("chainId", CHAIN); + m.put("bindHeight", bind); + m.put("count", rows.size()); + for (int i = 0; i < rows.size(); i++) { + m.set(Integer.toString(i), rows.get(i)); + } + return m; + } + + private static ObjectNode wrap( + final String description, final ObjectNode manifest, final boolean ecdsaRequired, + final String previous, final String expect) { + final ObjectNode v = M.createObjectNode(); + v.put("description", description); + v.put("chainId", CHAIN); + final ArrayNode req = v.putArray("requiredSchemes"); + req.add("falcon-512"); + req.add("slh-dsa-sha2-128s"); + v.put("ecdsaRequired", ecdsaRequired); + if (previous == null) { + v.putNull("previous"); + } else { + v.put("previous", previous); + } + v.put("expect", expect); + v.set("manifest", manifest); + return v; + } + + static Map both(final Proba p) { + final Map m = new java.util.LinkedHashMap<>(); + m.put("falcon-512", p.falcon()); + m.put("slh-dsa-sha2-128s", p.slh()); + return m; + } + + @Test + @EnabledIfEnvironmentVariable(named = "AERE_REG3_VECTORS_OUT", matches = ".+") + public void writeTheConformanceVectors() throws IOException { + final Path out = Path.of(System.getenv("AERE_REG3_VECTORS_OUT")); + Files.createDirectories(out); + final Proba v0 = proba(); + final Proba v1 = proba(); + final Proba v2 = proba(); + final Proba v3 = proba(); + final long bindA = 1000; + final long bindB = 2000; + final Draft d0 = new Draft(PqRegistryV3.idOf(v0.falconStd()), both(v0), v0); + final Draft d1 = new Draft(PqRegistryV3.idOf(v1.falconStd()), both(v1), v1); + final Draft d2 = new Draft(PqRegistryV3.idOf(v2.falconStd()), both(v2), v2); + + // epoch A: three rows, the switch epoch (ecdsa on every row), transport on row 0 + final List a = new ArrayList<>(); + a.add(rowJson(bindA, 3, 0, d0, true, true)); + a.add(rowJson(bindA, 3, 1, d1, true, false)); + a.add(rowJson(bindA, 3, 2, d2, true, false)); + final ObjectNode ma = manifest(bindA, a); + final ObjectNode okA = wrap("epoch A: 3 rows, falcon-512 + slh-dsa-sha2-128s, the switch epoch", ma, true, null, "ok"); + final PqRegistryV3.Registry ra = PqRegistryV3.load(ma, CHAIN, List.of("falcon-512", "slh-dsa-sha2-128s"), null, true); + addHashes(okA, ra); + write(out, "ok-epoch-a", okA); + + // epoch B: row 1 ROTATES both keys (keeps its id), row 2 unchanged, a new validator joins, v0 leaves + final Proba v1b = proba(); + final Draft d1b = new Draft(d1.id(), both(v1b), v1); + final Draft d3 = new Draft(PqRegistryV3.idOf(v3.falconStd()), both(v3), v3); + final List b = new ArrayList<>(); + b.add(rowJson(bindB, 3, 0, d2, false, false)); + final ObjectNode r1b = rowJson(bindB, 3, 1, d1b, false, false); + rotate(r1b, bindB, 3, 1, List.of(v1.falcon(), v1.slh())); + b.add(r1b); + b.add(rowJson(bindB, 3, 2, d3, false, false)); + final ObjectNode mb = manifest(bindB, b); + final ObjectNode okB = wrap("epoch B after A: index 1 rotates both keys under its id, index 0 unchanged (moved), a new id joins", mb, false, "ok-epoch-a", "ok"); + final PqRegistryV3.Registry rb = PqRegistryV3.load(mb, CHAIN, List.of("falcon-512", "slh-dsa-sha2-128s"), ra, false); + addHashes(okB, rb); + write(out, "ok-epoch-b-rotation", okB); + + // negatives on epoch A, one field each + ObjectNode m = ma.deepCopy(); + m.put("formatVersion", 2); + write(out, "n01-format", wrap("formatVersion 2", m, true, null, "AERE-PQC-REG3-01")); + m = ma.deepCopy(); + m.put("chainId", 2800); + write(out, "n02-chain", wrap("chainId of another chain", m, true, null, "AERE-PQC-REG3-02")); + m = ma.deepCopy(); + m.put("3", M.createObjectNode()); + write(out, "n03-extra-field", wrap("a row \"3\" beyond count 3", m, true, null, "AERE-PQC-REG3-03")); + m = ma.deepCopy(); + ((ObjectNode) m.get("1").get("keys")).put("ml-dsa-65", "0x00"); + ((ObjectNode) m.get("1").get("pop")).put("ml-dsa-65", "0x00"); + write(out, "n05-unknown-scheme", wrap("a key of a scheme this build does not know", m, true, null, "AERE-PQC-REG3-05")); + // n06: row 2 without its SLH-DSA key; pop and claim re-made so only the missing scheme can fail + { + final Map onlyF = new java.util.LinkedHashMap<>(); + onlyF.put("falcon-512", v2.falcon()); + final List x = new ArrayList<>(a); + x.set(2, rowJson(bindA, 3, 2, new Draft(d2.id(), onlyF, v2), true, false)); + write(out, "n06-required-scheme", wrap("row 2 carries no slh-dsa key", manifest(bindA, x), true, null, "AERE-PQC-REG3-06")); + } + m = ma.deepCopy(); + final String fk = m.get("0").get("keys").get("falcon-512").asText(); + ((ObjectNode) m.get("0").get("keys")).put("falcon-512", "0x0a" + fk.substring(4)); + write(out, "n07-falcon-header", wrap("row 0 falcon key with header 0x0a instead of 0x09", m, true, null, "AERE-PQC-REG3-07")); + m = ma.deepCopy(); + ((ObjectNode) m.get("1").get("pop")).put("falcon-512", m.get("0").get("pop").get("falcon-512").asText()); + write(out, "n08-pop", wrap("row 1 carries row 0's falcon possession proof", m, true, null, "AERE-PQC-REG3-08")); + // n09: row 1 filed under an id that is not its derivation; every proof re-made over that id + { + final byte[] wrongId = new byte[20]; + RNG.nextBytes(wrongId); + final List x = new ArrayList<>(a); + x.set(1, rowJson(bindA, 3, 1, new Draft(wrongId, both(v1), v1), true, false)); + write(out, "n09-id-derivation", wrap("row 1 id is not keccak(0x50||0x20||falcon)[12:]", manifest(bindA, x), true, null, "AERE-PQC-REG3-09")); + } + // n10: row 2 filed under row 1's id; proofs re-made + { + final List x = new ArrayList<>(a); + x.set(2, rowJson(bindA, 3, 2, new Draft(d1.id(), both(v2), v2), true, false)); + write(out, "n10-duplicate-id", wrap("row 2 carries row 1's id", manifest(bindA, x), true, null, "AERE-PQC-REG3-10")); + } + // n11: row 2 carries row 1's SLH-DSA key (its proof made by that key, over row 2) + { + final Map k = new java.util.LinkedHashMap<>(); + k.put("falcon-512", v2.falcon()); + k.put("slh-dsa-sha2-128s", v1.slh()); + final List x = new ArrayList<>(a); + x.set(2, rowJson(bindA, 3, 2, new Draft(d2.id(), k, v2), true, false)); + write(out, "n11-duplicate-key", wrap("row 2 carries row 1's slh-dsa key", manifest(bindA, x), true, null, "AERE-PQC-REG3-11")); + } + // n15: transport of 19 bytes (claim re-made over the row as written is impossible: the loader + // refuses the field before hashing, which is the point) + m = ma.deepCopy(); + ((ObjectNode) m.get("0").get("transport")).put("secp256k1Address", "0x" + "11".repeat(19)); + write(out, "n15-transport", wrap("row 0 transport is 19 bytes", m, true, null, "AERE-PQC-REG3-15")); + // n14: row 1's claim made by row 2's classical key; n14b: row 1 without the link in the switch epoch + { + final ObjectNode x1 = a.get(1).deepCopy(); + claim(x1, bindA, 3, 1, v2.ecdsa()); + ((ObjectNode) x1.get("ecdsa")).put("addr", hx(v1.address())); + final List x = new ArrayList<>(a); + x.set(1, x1); + write(out, "n14-ecdsa-wrong-key", wrap("row 1 claim signed by another validator's key", manifest(bindA, x), true, null, "AERE-PQC-REG3-14")); + final ObjectNode y1 = a.get(1).deepCopy(); + y1.remove("ecdsa"); + final List y = new ArrayList<>(a); + y.set(1, y1); + write(out, "n14b-ecdsa-missing", wrap("row 1 has no ecdsa link in the switch epoch", manifest(bindA, y), true, null, "AERE-PQC-REG3-14")); + } + m = ma.deepCopy(); + m.put("registryHash", "0x" + "00".repeat(32)); + write(out, "n16-declared-hash", wrap("the file declares a registry hash it does not have", m, true, null, "AERE-PQC-REG3-16")); + + // negatives on epoch B + { + final ObjectNode x1 = rowJson(bindB, 3, 1, d1b, false, false); + rotate(x1, bindB, 3, 1, List.of(v1.falcon())); + final List x = new ArrayList<>(b); + x.set(1, x1); + write(out, "n12-rotation-one-key", wrap("index 1 rotates with the old falcon key only (slh missing)", manifest(bindB, x), false, "ok-epoch-a", "AERE-PQC-REG3-12")); + final ObjectNode z1 = rowJson(bindB, 3, 1, d1b, false, false); + rotate(z1, bindB, 3, 1, List.of(v3.falcon(), v1.slh())); + final List z = new ArrayList<>(b); + z.set(1, z1); + write(out, "n12b-rotation-wrong-key", wrap("index 1 rotation's falcon signature is by another validator's key", manifest(bindB, z), false, "ok-epoch-a", "AERE-PQC-REG3-12")); + final ObjectNode u0 = rowJson(bindB, 3, 0, d2, false, false); + rotate(u0, bindB, 3, 0, List.of(v2.falcon(), v2.slh())); + final List u = new ArrayList<>(b); + u.set(0, u0); + write(out, "n13-rotation-unchanged", wrap("index 0 did not change its keys and carries a rotation", manifest(bindB, u), false, "ok-epoch-a", "AERE-PQC-REG3-13")); + } + } + + /** Adds rotation signatures over the row's current hash, one per given old key, in scheme order. */ + static void rotate( + final ObjectNode row, final long bind, final int count, final int index, + final List oldKeys) { + final Bytes32 msg = PqRegistryV3.rotateMessage(rowHashOf(row, bind, count, index)); + final ObjectNode rot = row.putObject("rotation"); + final String[] names = {"falcon-512", "slh-dsa-sha2-128s"}; + for (int i = 0; i < oldKeys.size(); i++) { + final SealScheme s = SealSchemes.byId(names[i]).orElseThrow(); + rot.put(names[i], hx(s.sign(oldKeys.get(i).privateKey(), msg.toArrayUnsafe()).orElseThrow())); + } + } + + private static void addHashes(final ObjectNode v, final PqRegistryV3.Registry r) { + v.put("registryHash", r.hash().toHexString()); + v.put("root", r.root().toHexString()); + final ArrayNode rh = v.putArray("rowHashes"); + r.rowHashes().forEach(h -> rh.add(h.toHexString())); + final ObjectNode proofs = v.putObject("merkleProofs"); + for (int i = 0; i < r.count(); i++) { + final ArrayNode p = proofs.putArray(Integer.toString(i)); + PqRegistryV3.merkleProof(r.rowHashes(), i).forEach(h -> p.add(h.toHexString())); + } + final ArrayNode ids = v.putArray("ids"); + r.rows().forEach(row -> ids.add(hx(row.id()))); + } + + private static void write(final Path dir, final String name, final JsonNode v) throws IOException { + Files.writeString(dir.resolve(name + ".json"), M.writeValueAsString(v) + "\n", StandardCharsets.UTF_8); + } + + private static String hx(final byte[] b) { + final StringBuilder sb = new StringBuilder("0x"); + for (final byte x : b) { + sb.append(String.format(Locale.ROOT, "%02x", x & 0xff)); + } + return sb.toString(); + } +} 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 index 41e03e8..e4218b1 100644 --- 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 @@ -116,6 +116,58 @@ class PqSchemeScheduleTest { assertThat(orar.firstUnsatisfied(reg, 4)).isPresent(); } + // ------------------------------------------------- intervale per schema (AIP-22 treapta 2) + + private static final String SLHDSA_CANONIC = "slh-dsa-sha2-128s"; + + @Test + void perSchemeIntervalsParseAndOnlyBiteOnTheirOwnGrid() { + // Falcon la fiecare al 32-lea bloc, SLH-DSA doar la fiecare al 128-lea, grilele numarate de la H0=64 + final PqSchemeSchedule orar = + PqSchemeSchedule.parse("1000:" + FALCON + "/32+" + SLHDSA_CANONIC + "/128"); + assertThat(orar.hasIntervals()).isTrue(); + // schemesAt ramane setul intreg: cheile si armabilitatea privesc toate schemele + assertThat(orar.schemesAt(1000)).containsExactlyInAnyOrder(FALCON, SLHDSA_CANONIC); + // 64 + 32k dar nu 64 + 128k: doar Falcon + assertThat(orar.schemesRequiredForAnchor(1056, 64)).containsExactly(FALCON); // 1056-64 = 992 = 31*32 + assertThat(orar.schemesRequiredForAnchor(1120, 64)).containsExactly(FALCON); // 1056 = 33*32, nu multiplu de 128 + // 64 + 128k: amandoua + assertThat(orar.schemesRequiredForAnchor(1216, 64)).containsExactlyInAnyOrder(FALCON, SLHDSA_CANONIC); // 1152 = 9*128 + // sub prima treapta (parintele 999 e sub 1000): nimic + assertThat(orar.schemesRequiredForAnchor(1000, 64)).isEmpty(); + // pe granita: treapta se citeste la PARINTE (1000), ancora e 1001... care nu e pe grila 32 de la 64 + assertThat(orar.schemesRequiredForAnchor(1001, 64)).isEmpty(); + } + + @Test + void withoutIntervalsEveryNamedSchemeIsRequiredAtEveryAnchorExactlyAsBefore() { + final PqSchemeSchedule orar = PqSchemeSchedule.parse("100:" + FALCON + "+" + SLHDSA); + assertThat(orar.hasIntervals()).isFalse(); + for (final long h : new long[] {101, 133, 200, 999_999}) { + assertThat(orar.schemesRequiredForAnchor(h, 64)).isEqualTo(orar.schemesAt(h - 1)); + } + // and a scheme WITHOUT an interval next to one WITH: the first at every anchor, the second on its grid + final PqSchemeSchedule mixt = PqSchemeSchedule.parse("100:" + FALCON + "+" + SLHDSA_CANONIC + "/128"); + assertThat(mixt.schemesRequiredForAnchor(96 + 32, 96)).containsExactly(FALCON); + assertThat(mixt.schemesRequiredForAnchor(96 + 128, 96)).containsExactlyInAnyOrder(FALCON, SLHDSA_CANONIC); + } + + @Test + void intervalsThatCannotLandOnAnchorsRefuseTheWholeSchedule() { + assertThatThrownBy(() -> PqSchemeSchedule.parse("100:" + FALCON + "/0")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("positive multiple"); + assertThatThrownBy(() -> PqSchemeSchedule.parse("100:" + FALCON + "/48")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("48"); + assertThatThrownBy(() -> PqSchemeSchedule.parse("100:" + FALCON + "/abc")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("not a number"); + assertThatThrownBy(() -> PqSchemeSchedule.parse("100:" + FALCON + "/32+" + FALCON + "/64")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("repeats"); + } + @Test void beforeTheFirstStepMeansLegacyNotSomeDefaultScheme() { final PqSchemeSchedule orar = PqSchemeSchedule.parse("500:falcon-512"); diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/validator/blockbased/VoteTallyIdentityTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/validator/blockbased/VoteTallyIdentityTest.java new file mode 100644 index 0000000..44a80d3 --- /dev/null +++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/validator/blockbased/VoteTallyIdentityTest.java @@ -0,0 +1,75 @@ +/* + * AERE AIP-22 stage 3: the ONE place the validator set changes form. After block H_id - 1 the vote + * tally holds the sorted post-quantum ids of the same members and open votes on addresses are gone; + * at every other block (and disarmed) the tally is untouched. + */ +package org.hyperledger.besu.consensus.common.validator.blockbased; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.hyperledger.besu.consensus.common.bft.AerePqIdentity; +import org.hyperledger.besu.consensus.common.bft.PqRegistryV3; +import org.hyperledger.besu.consensus.common.validator.ValidatorVote; +import org.hyperledger.besu.consensus.common.validator.VoteType; +import org.hyperledger.besu.datatypes.Address; + +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.TreeMap; +import java.util.TreeSet; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.tuweni.bytes.Bytes; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +public class VoteTallyIdentityTest { + + private static final long H_ID = 1500; + + @AfterEach + public void disarm() { + AerePqIdentity.uninstall(); + } + + @Test + public void theTallyTurnsIntoIdsAfterHidMinusOneAndDropsOpenVotes() throws Exception { + final JsonNode v; + try (InputStream in = getClass().getResourceAsStream("/aere/registry-v3/ok-epoch-a.json")) { + v = new ObjectMapper().readTree(in); + } + final PqRegistryV3.Registry epoch = + PqRegistryV3.load(v.get("manifest"), v.get("chainId").asLong(), + List.of("falcon-512", "slh-dsa-sha2-128s"), null, true); + final List
addr = new ArrayList<>(); + final List
id = new ArrayList<>(); + for (final PqRegistryV3.Row r : epoch.rows()) { + addr.add(Address.wrap(Bytes.wrap(r.ecdsaAddress()))); + id.add(Address.wrap(Bytes.wrap(r.id()))); + } + final TreeMap epochs = new TreeMap<>(); + epochs.put(epoch.bindHeight(), epoch); + + // disarmed: nothing happens at any block + final VoteTally before = new VoteTally(addr); + before.translateToIds(H_ID - 1); + assertThat(before.getValidators()).containsExactlyElementsOf(new TreeSet<>(addr)); + + AerePqIdentity.install(new AerePqIdentity.Config(H_ID, epochs)); + final VoteTally t = new VoteTally(addr); + final Address newcomer = Address.fromHexString("0x00000000000000000000000000000000000000cc"); + t.addVote(new ValidatorVote(VoteType.ADD, addr.get(0), newcomer)); + t.translateToIds(H_ID - 2); + assertThat(t.getValidators()).containsExactlyElementsOf(new TreeSet<>(addr)); + t.translateToIds(H_ID - 1); + assertThat(t.getValidators()).containsExactlyElementsOf(new TreeSet<>(id)); + // the open vote on an address is gone: two more votes for the newcomer would have reached the + // majority of three with it; after the switch they do not + t.addVote(new ValidatorVote(VoteType.ADD, id.get(1), newcomer)); + assertThat(t.getValidators()).doesNotContain(newcomer); + t.translateToIds(H_ID); + assertThat(t.getValidators()).hasSize(3); + } +} 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 index 918607a..680c53f 100644 --- 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 @@ -22,6 +22,7 @@ */ package org.hyperledger.besu.consensus.qbft.core.validation; +import org.hyperledger.besu.consensus.common.bft.AerePqIdentity; 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; @@ -207,7 +208,9 @@ public class CommitValidator { } final Address commitSealCreator = - Util.signatureToAddress(payload.getCommitSeal(), expectedCommitDigest); + AerePqIdentity.author( + Util.signatureToAddress(payload.getCommitSeal(), expectedCommitDigest), + targetRound.getSequenceNumber()); if (!commitSealCreator.equals(signedPayload.getAuthor())) { LOG.info( 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 index d1aa1be..0555883 100644 --- 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 @@ -18,6 +18,7 @@ */ package org.hyperledger.besu.consensus.qbft.core.validation; +import org.hyperledger.besu.consensus.common.bft.AerePqIdentity; import org.hyperledger.besu.consensus.common.bft.FalconSeal; import org.hyperledger.besu.consensus.common.bft.HybridSealSupport; import org.hyperledger.besu.consensus.common.bft.HybridSignerRegistry; @@ -113,6 +114,8 @@ public final class PqCommitEnforcement { private final PqSchemeSchedule schemeSchedule; private final HybridSignerRegistry hybridRegistry; private final LongPredicate extrasRequiredAt; + // AIP-22 stage 2: the origin of the anchor grids, read lazily so tests need no live config + private final java.util.function.LongSupplier anchorBlock; /** * @param armedFromBlock first block height (inclusive) at which enforcement applies; use @@ -179,11 +182,34 @@ public final class PqCommitEnforcement { final PqSchemeSchedule schemeSchedule, final HybridSignerRegistry hybridRegistry, final LongPredicate extrasRequiredAt) { + this( + armedFromBlock, + registry, + schemeSchedule, + hybridRegistry, + extrasRequiredAt, + () -> PqAnchorProducer.config().anchorBlock()); + } + + /** + * Full constructor with an explicit anchor-grid origin (AIP-22 stage 2), for proofs that run + * a per-scheme interval schedule without the process-wide anchor configuration. + * + * @param anchorBlock supplies the chain's anchor activation height, origin of every grid + */ + public PqCommitEnforcement( + final long armedFromBlock, + final PqSignerRegistry registry, + final PqSchemeSchedule schemeSchedule, + final HybridSignerRegistry hybridRegistry, + final LongPredicate extrasRequiredAt, + final java.util.function.LongSupplier anchorBlock) { this.armedFromBlock = armedFromBlock; this.registry = registry; this.schemeSchedule = schemeSchedule; this.hybridRegistry = hybridRegistry; this.extrasRequiredAt = extrasRequiredAt; + this.anchorBlock = anchorBlock; 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. @@ -243,6 +269,9 @@ public final class PqCommitEnforcement { /** * Every scheme the schedule names at this height, other than Falcon, must be present and valid. + * The height is an anchor PARENT (that is the only place extras are demanded), so the schemes + * are those the anchor at {@code height + 1} requires: with per-scheme intervals (AIP-22 stage + * 2) a commit at the parent of a Falcon-only anchor counts without an SLH-DSA seal. */ private Optional hybridRefusal( final long height, @@ -250,7 +279,8 @@ public final class PqCommitEnforcement { final Hash commitDigest, final FalconSeal falconSeal, final List extraSeals) { - final Set required = schemeSchedule.schemesAt(height); + final Set required = + schemeSchedule.schemesRequiredForAnchor(height + 1L, anchorBlock.getAsLong()); for (final String schemeId : required) { if (SealSchemes.FALCON_512.id().equals(schemeId)) { continue; // already decided by the Falcon path above @@ -284,7 +314,7 @@ public final class PqCommitEnforcement { } final Optional bound = hybridRegistry.address(found.getValidatorIndex()); if (bound.isEmpty() - || !Address.wrap(Bytes.wrap(bound.get())).equals(author)) { + || !AerePqIdentity.author(Address.wrap(Bytes.wrap(bound.get())), height).equals(author)) { return Optional.of( "AERE HIBRID: index " + found.getValidatorIndex() + " is not bound to the commit author " + author + " in the hybrid registry"); diff --git a/anchor/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/AereIdentityMessageTest.java b/anchor/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/AereIdentityMessageTest.java new file mode 100644 index 0000000..9124dc9 --- /dev/null +++ b/anchor/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/AereIdentityMessageTest.java @@ -0,0 +1,165 @@ +/* + * AERE AIP-22 stage 3: the identity switch, measured on real QBFT messages. + * + * Four upstream test nodes (QbftNodeList: a secp256k1 node key and a message factory each) get a + * registry v3 row each (a Falcon-512 key with its possession proof, and the ecdsa link signed by the + * node's own key). The validator set given to the upstream validators is the list of their IDS. + * + * What must hold, each with its negative: + * - armed, a Commit (and a Prepare) signed with the node's ECDSA key is accepted against the id set: + * the author is translated to the id, and the commit seal's creator is translated the same way; + * - DISARMED, the very same message is REFUSED against the id set (its author is an address): the + * translation, and nothing else, is what makes it pass; + * - armed but BELOW H_id, it is refused against ids and accepted against addresses; + * - a node with no row stays an address and is refused. + */ +package org.hyperledger.besu.consensus.qbft.core.validation; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.hyperledger.besu.consensus.common.bft.AerePqIdentity; +import org.hyperledger.besu.consensus.common.bft.ConsensusRoundIdentifier; +import org.hyperledger.besu.consensus.common.bft.PqRegistryV3; +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.messagewrappers.Prepare; +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.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +import org.apache.tuweni.bytes.Bytes; +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 AereIdentityMessageTest { + + private static final long CHAIN = 28001; + private static final long H_ID = 10; + private static final int N = 4; + + private final Hash expectedHash = Hash.fromHexStringLenient("0x1"); + private @Mock QbftBlockCodec qbftBlockCodec; + private QbftNodeList nodes; + private final List
ids = new ArrayList<>(); + + @BeforeEach + public void registryForTheNodes() { + nodes = QbftNodeList.createNodes(N, qbftBlockCodec); + final long bind = 5; + final List rows = new ArrayList<>(); + for (int i = 0; i < N; i++) { + final SealScheme f = SealSchemes.FALCON_512; + final SealScheme.GeneratedPair p = f.generate(SecureRandomProvider.createSecureRandom()); + final byte[] std = new byte[897]; + std[0] = 0x09; + System.arraycopy(p.publicRegistryForm(), 0, std, 1, 896); + final byte[] id = PqRegistryV3.idOf(std); + final Bytes32 popMsg = PqRegistryV3.popMessage(CHAIN, bind, N, i, id, f.wireId(), std); + final byte[] pop = f.sign(p.privateKey(), popMsg.toArrayUnsafe()).orElseThrow(); + final TreeMap keys = new TreeMap<>(); + keys.put(f.wireId() & 0xff, new PqRegistryV3.Key(f, std, pop)); + final PqRegistryV3.Row bare = new PqRegistryV3.Row(i, id, keys, null, Map.of(), null, null); + final Bytes32 rh = PqRegistryV3.rowHash(CHAIN, bind, N, bare); + final SECPSignature claim = + nodes.getNode(i).getNodeKey().sign(PqRegistryV3.claimDigest(CHAIN, rh)); + rows.add( + new PqRegistryV3.Row( + i, id, keys, null, Map.of(), + nodes.getNode(i).getAddress().getBytes().toArrayUnsafe(), + claim.encodedBytes().toArrayUnsafe())); + ids.add(Address.wrap(Bytes.wrap(id))); + } + final List hashes = new ArrayList<>(); + for (final PqRegistryV3.Row r : rows) { + hashes.add(PqRegistryV3.rowHash(CHAIN, bind, N, r)); + } + final Bytes32 root = PqRegistryV3.merkleRoot(hashes); + final PqRegistryV3.Registry reg = + new PqRegistryV3.Registry( + CHAIN, bind, N, rows, hashes, root, PqRegistryV3.registryHash(CHAIN, bind, N, root)); + final TreeMap epochs = new TreeMap<>(); + epochs.put(bind, reg); + AerePqIdentity.install(new AerePqIdentity.Config(H_ID, epochs)); + } + + @AfterEach + public void disarm() { + AerePqIdentity.uninstall(); + } + + private Commit commit(final int node, final ConsensusRoundIdentifier round) { + final SECPSignature seal = + nodes.getNode(node).getNodeKey().sign(Bytes32.wrap(expectedHash.getBytes())); + return nodes.getMessageFactory(node).createCommit(round, expectedHash, seal); + } + + @Test + public void armedACommitSignedByTheNodeKeyIsAcceptedAgainstTheIdSet() { + final ConsensusRoundIdentifier round = new ConsensusRoundIdentifier(H_ID, 0); + final CommitValidator v = new CommitValidator(ids, round, expectedHash, expectedHash); + for (int i = 0; i < N; i++) { + final Commit c = commit(i, round); + assertThat(c.getAuthor()).isEqualTo(ids.get(i)); + assertThat(v.validate(c)).as("node " + i).isTrue(); + } + } + + @Test + public void disarmedTheSameCommitIsRefusedAgainstTheIdSet() { + final ConsensusRoundIdentifier round = new ConsensusRoundIdentifier(H_ID, 0); + AerePqIdentity.uninstall(); + final CommitValidator v = new CommitValidator(ids, round, expectedHash, expectedHash); + final Commit c = commit(0, round); + assertThat(c.getAuthor()).isEqualTo(nodes.getNode(0).getAddress()); + assertThat(v.validate(c)).isFalse(); + } + + @Test + public void belowHidTheAuthorIsTheAddress() { + final ConsensusRoundIdentifier round = new ConsensusRoundIdentifier(H_ID - 1, 0); + final Commit c = commit(1, round); + assertThat(c.getAuthor()).isEqualTo(nodes.getNode(1).getAddress()); + assertThat(new CommitValidator(ids, round, expectedHash, expectedHash).validate(c)).isFalse(); + assertThat( + new CommitValidator(nodes.getNodeAddresses(), round, expectedHash, expectedHash) + .validate(c)) + .isTrue(); + } + + @Test + public void aNodeWithoutARowStaysAnAddressAndIsRefused() { + final ConsensusRoundIdentifier round = new ConsensusRoundIdentifier(H_ID + 3, 0); + final QbftNode stranger = QbftNode.create(qbftBlockCodec); + final SECPSignature seal = stranger.getNodeKey().sign(Bytes32.wrap(expectedHash.getBytes())); + final Commit c = stranger.getMessageFactory().createCommit(round, expectedHash, seal); + assertThat(c.getAuthor()).isEqualTo(stranger.getAddress()); + assertThat(new CommitValidator(ids, round, expectedHash, expectedHash).validate(c)).isFalse(); + } + + @Test + public void armedAPrepareIsAuthoredByTheId() { + final ConsensusRoundIdentifier round = new ConsensusRoundIdentifier(H_ID + 1, 0); + for (int i = 0; i < N; i++) { + final Prepare p = nodes.getMessageFactory(i).createPrepare(round, expectedHash); + assertThat(p.getAuthor()).isEqualTo(ids.get(i)); + assertThat(new PrepareValidator(ids, round, expectedHash).validate(p)).isTrue(); + } + AerePqIdentity.uninstall(); + final Prepare p = nodes.getMessageFactory(2).createPrepare(round, expectedHash); + assertThat(new PrepareValidator(ids, round, expectedHash).validate(p)).isFalse(); + } +} 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 index cf8c683..95b4b66 100644 --- 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 @@ -190,6 +190,45 @@ public class PqHybridEnforcementTest { .isEmpty(); } + // ============================================ intervale per schema (AIP-22 treapta 2) + + /** + * With SLH-DSA on the 128 grid (Falcon on the 32 grid), counted from an activation height of 64, + * a commit at the parent of a Falcon-only anchor counts with the Falcon seal alone, and a commit + * at the parent of a hybrid anchor does not. The two heights differ in nothing but the grid; the + * grid origin is the negative control (read against another origin, the two heights swap). + */ + @Test + public void withPerSchemeIntervalsTheSlhSealIsDemandedOnlyAtParentsOfAnchorsOnItsGrid() { + final PqSchemeSchedule cuIntervale = + PqSchemeSchedule.parse( + H_ARMARE + ":" + SealSchemes.FALCON_512.id() + "/32+" + SealSchemes.SLH_DSA_128S.id() + "/128"); + final long origine = 64L; + long ancoraHibrida = origine; + while (ancoraHibrida <= H_HIBRID) { + ancoraHibrida += 128L; + } + final long ancoraDoarFalcon = ancoraHibrida + 32L; + final PqCommitEnforcement e = + new PqCommitEnforcement(H_ARMARE, registruFalcon(), cuIntervale, registry, h -> true, () -> origine); + assertThat(e.refusal(ancoraDoarFalcon - 1, VALIDATOR_0, DIGEST, Optional.of(sigiliuFalcon(0)), List.of())) + .isEmpty(); + final Optional refusal = + e.refusal(ancoraHibrida - 1, VALIDATOR_0, DIGEST, Optional.of(sigiliuFalcon(0)), List.of()); + assertThat(refusal).isPresent(); + assertThat(refusal.get()).contains("carries no").contains(SealSchemes.SLH_DSA_128S.id()); + assertThat( + e.refusal( + ancoraHibrida - 1, VALIDATOR_0, DIGEST, Optional.of(sigiliuFalcon(0)), List.of(sigiliuSlh(0, DIGEST)))) + .isEmpty(); + final PqCommitEnforcement altaOrigine = + new PqCommitEnforcement(H_ARMARE, registruFalcon(), cuIntervale, registry, h -> true, () -> origine + 32L); + assertThat(altaOrigine.refusal(ancoraHibrida - 1, VALIDATOR_0, DIGEST, Optional.of(sigiliuFalcon(0)), List.of())) + .isEmpty(); + assertThat(altaOrigine.refusal(ancoraDoarFalcon - 1, VALIDATOR_0, DIGEST, Optional.of(sigiliuFalcon(0)), List.of())) + .isPresent(); + } + // ============================================================ controalele negative @Test 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 78423af..694c01d 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 @@ -14,6 +14,7 @@ */ package org.hyperledger.besu.consensus.qbft.headervalidationrules; +import org.hyperledger.besu.consensus.common.bft.AerePqIdentity; import org.hyperledger.besu.consensus.common.bft.BftContext; import org.hyperledger.besu.consensus.common.bft.BftExtraData; import org.hyperledger.besu.consensus.common.bft.FalconSeal; @@ -423,8 +424,21 @@ public class PqAnchorSealsRule implements AttachedBlockHeaderValidationRule { return false; } final HybridSealSupport hybrid = HybridSealSupport.instance(); + // Two sets, and they differ only with per-scheme intervals (AIP-22 stage 2). NAMED: every scheme + // the step at the parent height names; a seal of any other scheme is refused as today. REQUIRED: + // the named schemes whose grid THIS anchor height sits on, counted from the chain's anchor + // activation height; each must reach K. A seal of a named-but-not-required scheme is a surplus: + // verified and counted like any surplus seal, never ignored, never a reason to refuse. + final Set namedSchemes = new HashSet<>(); final Set requiredSchemes = new HashSet<>(); - hybrid.schedule().ifPresent(sch -> requiredSchemes.addAll(sch.schemesAt(parent.getNumber()))); + hybrid + .schedule() + .ifPresent( + sch -> { + namedSchemes.addAll(sch.schemesAt(parent.getNumber())); + requiredSchemes.addAll(sch.schemesRequiredForAnchor(number, config.anchorBlock())); + }); + namedSchemes.remove(SealSchemes.FALCON_512.id()); requiredSchemes.remove(SealSchemes.FALCON_512.id()); final Map counted = new HashMap<>(); for (final SchemeSeal s : seals) { @@ -432,14 +446,14 @@ public class PqAnchorSealsRule implements AttachedBlockHeaderValidationRule { continue; } final Optional scheme = SealSchemes.byWireId(s.getSchemeWireId()); - if (scheme.isEmpty() || !requiredSchemes.contains(scheme.get().id())) { + if (scheme.isEmpty() || !namedSchemes.contains(scheme.get().id())) { LOG.warn( "AERE PQ ANCHOR (R2): block {} REJECTED - the v2 certificate carries a seal of scheme 0x{}," + " which the scheme schedule does not name at the parent height {} (named: {}).", number, Integer.toHexString(s.getSchemeWireId() & 0xff), parent.getNumber(), - requiredSchemes); + namedSchemes); return false; } if (!falconIndices.contains(s.getValidatorIndex())) { @@ -452,7 +466,10 @@ public class PqAnchorSealsRule implements AttachedBlockHeaderValidationRule { return false; } final Optional registry = hybrid.registry(); - if (registry.isEmpty()) { + // AERE AIP-22 stage 3b: from H_k the key comes from the v3 epoch in force at the PARENT (the height the + // Falcon key of the same certificate is taken at); the hybrid registry is then not the key set any more + final boolean v3Keys = AerePqIdentity.keysFromV3(parent.getNumber()); + if (registry.isEmpty() && !v3Keys) { LOG.warn( "AERE PQ ANCHOR (R2): block {} REJECTED - the certificate carries {} seals and this node" + " has NO hybrid registry loaded, so it cannot verify them. A node that cannot" @@ -461,7 +478,10 @@ public class PqAnchorSealsRule implements AttachedBlockHeaderValidationRule { scheme.get().id()); return false; } - final Optional pk = registry.get().publicKey(s.getValidatorIndex(), scheme.get().id()); + final Optional pk = + v3Keys + ? AerePqIdentity.v3KeyAt(parent.getNumber(), s.getValidatorIndex(), scheme.get().id()) + : registry.get().publicKey(s.getValidatorIndex(), scheme.get().id()); if (pk.isEmpty()) { LOG.warn( "AERE PQ ANCHOR (R2): block {} REJECTED - the hybrid registry holds no {} key for index {}.", diff --git a/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/AereIdentityHeaderTest.java b/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/AereIdentityHeaderTest.java new file mode 100644 index 0000000..e51e617 --- /dev/null +++ b/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/AereIdentityHeaderTest.java @@ -0,0 +1,144 @@ +/* + * AERE AIP-22 stage 3: the identity switch on the IMPORT path - the committed seals of a real header. + * + * Four node keys seal a QBFT header (BftExtraDataFixture signs each committed seal over the + * committed-seal hash of that very header, as a live committer does). The committers recovered from + * it, through BOTH places upstream recovers them (BftBlockInterface.getCommitters for the header + * rules, BftBlockHashing.recoverCommitterAddresses for the rest), must be the v3 ids from H_id, and + * the classical addresses one block below H_id or with the switch disarmed. That is exactly what lets + * BftCommitSealsValidationRule accept a header sealed by ids against a validator set of ids. + */ +package org.hyperledger.besu.consensus.qbft; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.hyperledger.besu.consensus.common.bft.AerePqIdentity; +import org.hyperledger.besu.consensus.common.bft.BftBlockHashing; +import org.hyperledger.besu.consensus.common.bft.BftBlockHeaderFunctions; +import org.hyperledger.besu.consensus.common.bft.BftBlockInterface; +import org.hyperledger.besu.consensus.common.bft.BftExtraData; +import org.hyperledger.besu.consensus.common.bft.BftExtraDataCodec; +import org.hyperledger.besu.consensus.common.bft.BftExtraDataFixture; +import org.hyperledger.besu.consensus.common.bft.PqRegistryV3; +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.cryptoservices.NodeKey; +import org.hyperledger.besu.cryptoservices.NodeKeyUtils; +import org.hyperledger.besu.datatypes.Address; +import org.hyperledger.besu.ethereum.core.BlockHeader; +import org.hyperledger.besu.ethereum.core.BlockHeaderTestFixture; +import org.hyperledger.besu.ethereum.core.Util; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.TreeMap; +import java.util.TreeSet; + +import org.apache.tuweni.bytes.Bytes; +import org.apache.tuweni.bytes.Bytes32; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +public class AereIdentityHeaderTest { + + private static final long CHAIN = 28001; + private static final long H_ID = 20; + private final QbftExtraDataCodec codec = new QbftExtraDataCodec(); + private final List keys = new ArrayList<>(); + private final List
addr = new ArrayList<>(); + private final List
ids = new ArrayList<>(); + + @BeforeEach + public void registry() { + final long bind = 10; + final List rows = new ArrayList<>(); + for (int i = 0; i < 4; i++) { + final NodeKey k = NodeKeyUtils.generate(); + keys.add(k); + addr.add(Util.publicKeyToAddress(k.getPublicKey())); + final SealScheme f = SealSchemes.FALCON_512; + final SealScheme.GeneratedPair p = f.generate(SecureRandomProvider.createSecureRandom()); + final byte[] std = new byte[897]; + std[0] = 0x09; + System.arraycopy(p.publicRegistryForm(), 0, std, 1, 896); + final byte[] id = PqRegistryV3.idOf(std); + final byte[] pop = + f.sign( + p.privateKey(), + PqRegistryV3.popMessage(CHAIN, bind, 4, i, id, f.wireId(), std).toArrayUnsafe()) + .orElseThrow(); + final TreeMap km = new TreeMap<>(); + km.put(f.wireId() & 0xff, new PqRegistryV3.Key(f, std, pop)); + final Bytes32 rh = + PqRegistryV3.rowHash( + CHAIN, bind, 4, new PqRegistryV3.Row(i, id, km, null, Map.of(), null, null)); + final SECPSignature claim = k.sign(PqRegistryV3.claimDigest(CHAIN, rh)); + rows.add( + new PqRegistryV3.Row( + i, id, km, null, Map.of(), addr.get(i).getBytes().toArrayUnsafe(), + claim.encodedBytes().toArrayUnsafe())); + ids.add(Address.wrap(Bytes.wrap(id))); + } + final List hashes = new ArrayList<>(); + for (final PqRegistryV3.Row r : rows) { + hashes.add(PqRegistryV3.rowHash(CHAIN, bind, 4, r)); + } + final Bytes32 root = PqRegistryV3.merkleRoot(hashes); + final TreeMap epochs = new TreeMap<>(); + epochs.put( + bind, + new PqRegistryV3.Registry( + CHAIN, bind, 4, rows, hashes, root, PqRegistryV3.registryHash(CHAIN, bind, 4, root))); + AerePqIdentity.install(new AerePqIdentity.Config(H_ID, epochs)); + } + + @AfterEach + public void disarm() { + AerePqIdentity.uninstall(); + } + + /** A header at this height, listing these validators, sealed by all four keys. */ + private BlockHeader sealedHeader(final long number, final List
validators) { + final BlockHeaderTestFixture b = new BlockHeaderTestFixture(); + b.number(number); + b.gasLimit(5000); + b.timestamp(6 * number); + b.coinbase(validators.get(0)); + b.blockHeaderFunctions(BftBlockHeaderFunctions.forCommittedSeal(codec)); + final BftExtraData ed = + BftExtraDataFixture.createExtraData( + b.buildHeader(), + Bytes.wrap(new byte[BftExtraDataCodec.EXTRA_VANITY_LENGTH]), + Optional.empty(), + new ArrayList<>(new TreeSet<>(validators)), + keys, + 0, + codec); + b.extraData(codec.encode(ed)); + return b.buildHeader(); + } + + @Test + public void fromHidTheCommittersOfARealHeaderAreTheIds() { + final BlockHeader h = sealedHeader(H_ID, ids); + assertThat(new BftBlockInterface(codec).getCommitters(h)).containsExactlyElementsOf(ids); + assertThat(new BftBlockHashing(codec).recoverCommitterAddresses(h, codec.decode(h))) + .containsExactlyElementsOf(ids); + } + + @Test + public void belowHidAndDisarmedTheyAreTheAddresses() { + final BlockHeader below = sealedHeader(H_ID - 1, addr); + assertThat(new BftBlockInterface(codec).getCommitters(below)).containsExactlyElementsOf(addr); + final BlockHeader at = sealedHeader(H_ID, ids); + AerePqIdentity.uninstall(); + assertThat(new BftBlockInterface(codec).getCommitters(at)).containsExactlyElementsOf(addr); + assertThat(new BftBlockHashing(codec).recoverCommitterAddresses(at, codec.decode(at))) + .containsExactlyElementsOf(addr); + } +} diff --git a/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorV2RulesTest.java b/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorV2RulesTest.java index 4c542fe..867b46b 100644 --- a/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorV2RulesTest.java +++ b/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorV2RulesTest.java @@ -197,6 +197,40 @@ public class PqAnchorV2RulesTest { // --------------------------------------------------------------------------------------- R2 + /** + * AIP-22 stage 2, per-scheme intervals: Falcon on the 32 grid and SLH-DSA on the 128 grid, both + * counted from H. At an anchor on the 128 grid the certificate must reach K SLH-DSA seals; at an + * anchor on the 32 grid only, K Falcon seals suffice, and SLH-DSA seals carried there anyway are a + * surplus that is VERIFIED (a tampered one still refuses), not ignored and not refused. + */ + @Test + public void r2WithPerSchemeIntervalsDemandsSlhDsaOnlyOnItsGridAndVerifiesSurplus() throws Exception { + armHybrid("0:falcon-512/32+" + SLH + "/128"); + long hybridAnchor = H; + while (hybridAnchor <= V2) { + hybridAnchor += 128L; + } + final long falconOnlyAnchor = hybridAnchor + 32L; + final BlockHeader p1 = parentHeader(hybridAnchor - 1L); + assertThat(r2.validate(honestV2(hybridAnchor, p1, certificate(p1, K, 0)), p1, context())).isFalse(); + assertThat(r2.validate(honestV2(hybridAnchor, p1, certificate(p1, K, K)), p1, context())).isTrue(); + final BlockHeader p2 = parentHeader(falconOnlyAnchor - 1L); + assertThat(r2.validate(honestV2(falconOnlyAnchor, p2, certificate(p2, K, 0)), p2, context())).isTrue(); + assertThat(r2.validate(honestV2(falconOnlyAnchor, p2, certificate(p2, K, K)), p2, context())).isTrue(); + final List surplus = new ArrayList<>(certificate(p2, K, K)); + for (int i = 0; i < surplus.size(); i++) { + final SchemeSeal s = surplus.get(i); + if (s.getSchemeWireId() == SealSchemes.SLH_DSA_128S.wireId()) { + final byte[] b = s.getSignature().toArray(); + b[b.length / 2] ^= 1; + surplus.set(i, new SchemeSeal(s.getSchemeWireId(), s.getValidatorIndex(), Bytes.wrap(b))); + break; + } + } + surplus.sort(PqAnchorV2.CANONICAL); + assertThat(r2.validate(honestV2(falconOnlyAnchor, p2, surplus), p2, context())).isFalse(); + } + @Test public void r2AcceptsTheHonestV2HeaderWithKSealsPerScheme() { final BlockHeader parent = parentHeader(V2 + 4L);