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

This commit is contained in:
Aere Network 2026-09-23 20:08:22 +03:00
parent a94c0f2686
commit 099908a4ca
28 changed files with 4034 additions and 58 deletions

View File

@ -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 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. `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 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 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. 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 Every file below is Apache-2.0 material redistributed under section 4: it keeps its original

View File

@ -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 329e4e2f93143cc8af092f0a951fa23619c12c4ec1793b053654f8e26e088da0 app/src/main/java/org/hyperledger/besu/cli/BesuCommand.java
3f289d74bb1878280a99742320d7a27c19146a2f19cdf6a0857f2aad4361ad5e app/src/main/java/org/hyperledger/besu/cli/options/AerePqEmergencyOptions.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 129493150c0b13e8020bdf50c0c1ae07268d4f73a69a821b303bf4894451fa5e app/src/test/java/org/hyperledger/besu/cli/options/AerePqEmergencyOptionsTest.java
728c29c299edf6011f2c2798cbe930a043112e0e06b924e7edb54284ca44a758 config/src/main/java/org/hyperledger/besu/config/JsonGenesisConfigOptions.java 728c29c299edf6011f2c2798cbe930a043112e0e06b924e7edb54284ca44a758 config/src/main/java/org/hyperledger/besu/config/JsonGenesisConfigOptions.java
a3b93f2602e9755d91358c9b3f473235f4d40ae23d912f797cdb03ed7e1348f9 consensus/common/build.gradle a3b93f2602e9755d91358c9b3f473235f4d40ae23d912f797cdb03ed7e1348f9 consensus/common/build.gradle
68d635954ef33448a661256f50642eeeff2f7bb5874a54ffc2e64193c83fe391 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/AereAnchorProposalDelay.java 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 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 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 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 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 dc4c68ad18d64aedae2363fc83f7a5329d258e749b9f7cbe145ba8496139785e 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 570f7825afdb0dd10e1d920f7de8ae21a85f196e77edbd8da8de94994d845839 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 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 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 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 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 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 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 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 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 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 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 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 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 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 454eeca487505e70c7f0000f5401b6bf4530da1d5ce6bcb803427f1d08e3cb1c 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 49c3fd97e7985966531928af173b6f38ac48ff9dc8b5be1106e5b7bfb9c7b2b6 consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqForkGateFeedTest.java

View File

@ -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** | | 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) | | 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) | | 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 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 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 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 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 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 ### The files

View File

@ -30,6 +30,7 @@ import org.hyperledger.besu.config.QbftFork;
import org.hyperledger.besu.consensus.common.BftValidatorOverrides; import org.hyperledger.besu.consensus.common.BftValidatorOverrides;
import org.hyperledger.besu.consensus.common.EpochManager; import org.hyperledger.besu.consensus.common.EpochManager;
import org.hyperledger.besu.consensus.common.ForksSchedule; 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.BftBlockInterface;
import org.hyperledger.besu.consensus.common.bft.BftContext; import org.hyperledger.besu.consensus.common.bft.BftContext;
import org.hyperledger.besu.consensus.common.bft.BftEventQueue; 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. // about what is actually configured on this node.
final PqAnchorConfig aereAnchorConfig = PqAnchorConfig.fromSystemConfiguration(); 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<String>) 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( PqAnchorSyncModeGuard.verifySyncModeOrAbort(
syncConfig == null || syncConfig.getSyncMode() == null syncConfig == null || syncConfig.getSyncMode() == null
? null ? null
@ -487,6 +518,30 @@ public class QbftBesuControllerBuilder extends BesuControllerBuilder {
+ "node starts with an empty cache, exactly as before this repair.", + "node starts with an empty cache, exactly as before this repair.",
e.toString()); 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 = final BftExecutors bftExecutors =

View File

@ -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<Long, PqRegistryV3.Registry> 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<Long, PqRegistryV3.Registry> 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<Config> 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<List<String>> requiredAt) {
final String block = System.getProperty(PROPERTY_BLOCK);
if (block == null || block.isBlank()) {
return;
}
final long h = Long.parseLong(block.trim());
final List<String> requiredSchemes = requiredAt.apply(h);
final String files = System.getProperty(PROPERTY_REGISTRIES, "");
final String hashes = System.getProperty(PROPERTY_HASHES, "");
final TreeMap<Long, Bytes32> 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<PqRegistryV3.Registry> loaded = new ArrayList<>();
PqRegistryV3.Registry previous = null;
final List<Path> 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<Long, com.fasterxml.jackson.databind.JsonNode> 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<Long, PqRegistryV3.Registry> 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<byte[]> 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<Integer> v3IndexOfKey(
final long height, final String schemeId, final byte[] publicKey) {
final Optional<PqRegistryV3.Registry> r = epochAt(height);
if (r.isEmpty() || publicKey == null) {
return Optional.empty();
}
for (final PqRegistryV3.Row row : r.get().rows()) {
final Optional<byte[]> 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<Address> 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<Integer, String, Optional<byte[]>> 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<byte[]> 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<PqRegistryV3.Registry> 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<byte[]> 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<Bytes> relinked = new java.util.HashSet<>();
for (final PqRegistryV3.Registry e : c.epochs().headMap(height, true).descendingMap().values()) {
final Optional<PqRegistryV3.Row> 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<PqRegistryV3.Row> 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<Address> translateSetAfter(
final Collection<Address> 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<Address> ids = new TreeSet<>();
for (final Address a : set) {
final Optional<PqRegistryV3.Row> 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<Collection<Address>> transportTargets(final Collection<Address> recipients) {
final Config c = config;
if (c == null) {
return Optional.of(recipients);
}
final PqRegistryV3.Registry head = c.epochs().lastEntry().getValue();
final List<Address> out = new ArrayList<>();
for (final Address a : recipients) {
final Optional<PqRegistryV3.Row> 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);
}
}

View File

@ -156,7 +156,7 @@ public class BftBlockInterface implements BlockInterface {
bftExtraDataCodec)); bftExtraDataCodec));
return bftExtraData.getSeals().stream() return bftExtraData.getSeals().stream()
.map(p -> Util.signatureToAddress(p, committerHash)) .map(p -> AerePqIdentity.author(Util.signatureToAddress(p, committerHash), header.getNumber()))
.collect(Collectors.toList()); .collect(Collectors.toList());
} }
} }

View File

@ -151,6 +151,27 @@ public final class FalconSealSupport {
private final boolean signingEnabled; private final boolean signingEnabled;
private final int localIndex; private final int localIndex;
private final FalconPrivateKeyParameters localPrivateKey; 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<FalconPrivateKeyParameters> nextPrivateKeys;
/** The local key and index resolved per v3 epoch (bindHeight), from H_k. */
private final Map<Long, java.util.Optional<LocalKey>> 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<Integer, FalconPublicKeyParameters> registry; private final Map<Integer, FalconPublicKeyParameters> 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<FalconPrivateKeyParameters> 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.registry = reg;
this.indexToAddress = regAddr; this.indexToAddress = regAddr;
this.localIndex = idx; this.localIndex = idx;
@ -2524,6 +2575,89 @@ public final class FalconSealSupport {
return signingEnabled; 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<FalconPrivateKeyParameters> mine = new java.util.ArrayList<>();
if (localPrivateKey != null) {
mine.add(localPrivateKey);
}
mine.addAll(nextPrivateKeys);
for (final FalconPrivateKeyParameters k : mine) {
final java.util.Optional<Integer> 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<AerePqIdentity.Config> 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. * 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 // 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 // FalconSigner underneath, but the path is now the one the hybrid will use too; the 751
// baseline tests prove the equivalence. // 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<byte[]> sig = final java.util.Optional<byte[]> sig =
((FalconSealScheme) SealSchemes.FALCON_512).signWithParams(localPrivateKey, commitHash.toArray()); ((FalconSealScheme) SealSchemes.FALCON_512).signWithParams(lk.key(), commitHash.toArray());
if (sig.isEmpty()) { if (sig.isEmpty()) {
LOG.warn("AERE PQC: Falcon signing failed (ECDSA seal unaffected)"); LOG.warn("AERE PQC: Falcon signing failed (ECDSA seal unaffected)");
return Optional.empty(); 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()) { if (blockNumber < prepareAttachBlock()) {
return Optional.empty(); return Optional.empty();
} }
final LocalKey lk = localAt(blockNumber); // AERE AIP-22 stage 3b-ii
if (lk == null) {
return Optional.empty();
}
final java.util.Optional<byte[]> sig = final java.util.Optional<byte[]> sig =
((FalconSealScheme) SealSchemes.FALCON_512).signWithParams(localPrivateKey, message.toArray()); ((FalconSealScheme) SealSchemes.FALCON_512).signWithParams(lk.key(), message.toArray());
if (sig.isEmpty()) { if (sig.isEmpty()) {
LOG.warn("AERE PQ PREPARE: Falcon signing failed (ECDSA path unaffected)"); LOG.warn("AERE PQ PREPARE: Falcon signing failed (ECDSA path unaffected)");
return Optional.empty(); return Optional.empty();
@ -3570,7 +3712,7 @@ public final class FalconSealSupport {
} else if (n % 500L == 0L) { } else if (n % 500L == 0L) {
LOG.info("AERE PQ PREPARE: {} seals emitted on PREPAREs since startup.", n); 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()) { if (blockNumber < proposalAttachBlock()) {
return Optional.empty(); return Optional.empty();
} }
final LocalKey lk = localAt(blockNumber); // AERE AIP-22 stage 3b-ii
if (lk == null) {
return Optional.empty();
}
final java.util.Optional<byte[]> sig = final java.util.Optional<byte[]> sig =
((FalconSealScheme) SealSchemes.FALCON_512).signWithParams(localPrivateKey, message.toArray()); ((FalconSealScheme) SealSchemes.FALCON_512).signWithParams(lk.key(), message.toArray());
if (sig.isEmpty()) { if (sig.isEmpty()) {
LOG.warn("AERE PQ PROPOSAL: Falcon signing failed (ECDSA path unaffected)"); LOG.warn("AERE PQ PROPOSAL: Falcon signing failed (ECDSA path unaffected)");
return Optional.empty(); return Optional.empty();
@ -3672,7 +3818,7 @@ public final class FalconSealSupport {
} else if (n % 500L == 0L) { } else if (n % 500L == 0L) {
LOG.info("AERE PQ PROPOSAL: {} seals emitted on PROPOSALs since startup.", n); 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()) { if (blockNumber < roundChangeAttachBlock()) {
return Optional.empty(); return Optional.empty();
} }
final LocalKey lk = localAt(blockNumber); // AERE AIP-22 stage 3b-ii
if (lk == null) {
return Optional.empty();
}
final java.util.Optional<byte[]> sig = final java.util.Optional<byte[]> sig =
((FalconSealScheme) SealSchemes.FALCON_512).signWithParams(localPrivateKey, message.toArray()); ((FalconSealScheme) SealSchemes.FALCON_512).signWithParams(lk.key(), message.toArray());
if (sig.isEmpty()) { if (sig.isEmpty()) {
LOG.warn("AERE PQ ROUNDCHANGE: Falcon signing failed (ECDSA path unaffected)"); LOG.warn("AERE PQ ROUNDCHANGE: Falcon signing failed (ECDSA path unaffected)");
return Optional.empty(); return Optional.empty();
@ -3771,7 +3921,7 @@ public final class FalconSealSupport {
} else if (n % 100L == 0L) { } else if (n % 100L == 0L) {
LOG.info("AERE PQ ROUNDCHANGE: {} seals emitted on ROUND-CHANGEs since startup.", n); 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( private FalconPublicKeyParameters keyAt(
final long blockNumber, final int validatorIndex, final boolean historic) { 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.Schedule schedule = this.registryBindingSchedule;
final PqRegistryHash.RegistrySet set = this.registryBindingSet; final PqRegistryHash.RegistrySet set = this.registryBindingSet;
if (schedule == null || set == null) { if (schedule == null || set == null) {
@ -3868,6 +4027,20 @@ public final class FalconSealSupport {
return null; 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<byte[]> 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. * 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 * @return the bound address, or null when nothing binds that index at that height
*/ */
public Address addressForIndexAtHistoric(final long blockNumber, final int validatorIndex) { 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 * @return the bound address, or null
*/ */
public Address addressForIndexAtOwnHead(final long blockNumber, final int validatorIndex) { 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);
} }
/** /**

View File

@ -43,10 +43,20 @@ public final class HybridSealProducer {
private final PqSchemeSchedule schedule; private final PqSchemeSchedule schedule;
private final int validatorIndex; private final int validatorIndex;
private final Map<String, SealScheme.PrivateHandle> localKeys; private final Map<String, SealScheme.PrivateHandle> 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<String, List<SealScheme.PrivateHandle>> nextKeys;
private final java.util.function.LongToIntFunction localIndexAt;
private final Map<String, Optional<SealScheme.PrivateHandle>> 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 // one shout per missing scheme, not one per block: a permanent alarm is learned and ignored
private final Set<String> alreadyShouted = new java.util.HashSet<>(); private final Set<String> 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 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 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 validatorIndex this node's index, written into every seal it produces
@ -57,10 +67,94 @@ public final class HybridSealProducer {
final PqSchemeSchedule schedule, final PqSchemeSchedule schedule,
final int validatorIndex, final int validatorIndex,
final Map<String, SealScheme.PrivateHandle> localKeys) { final Map<String, SealScheme.PrivateHandle> 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<String, SealScheme.PrivateHandle> localKeys,
final long anchorBlock) {
this.attachFromBlock = schedule == null ? NEVER : attachFromBlock; this.attachFromBlock = schedule == null ? NEVER : attachFromBlock;
this.schedule = schedule; this.schedule = schedule;
this.validatorIndex = validatorIndex; this.validatorIndex = validatorIndex;
this.localKeys = localKeys == null ? Map.of() : Map.copyOf(localKeys); 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<String, SealScheme.PrivateHandle> localKeys,
final long anchorBlock,
final Map<String, List<SealScheme.PrivateHandle>> 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<SealScheme.PrivateHandle> 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<byte[]> rowKey = AerePqIdentity.v3KeyAt(height, index, scheme.id());
if (rowKey.isEmpty()) {
return Optional.empty();
}
final List<SealScheme.PrivateHandle> 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<byte[]> 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. */ /** A producer that never emits anything: the configuration of every node today. */
@ -84,6 +178,11 @@ public final class HybridSealProducer {
* <p>Never throws: a producer fault must never take down the ECDSA commit path. Every refusal * <p>Never throws: a producer fault must never take down the ECDSA commit path. Every refusal
* is a logged reason plus an empty list, exactly the stance of the Falcon signer. * is a logged reason plus an empty list, exactly the stance of the Falcon signer.
* *
* <p>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 blockNumber the height of the block being committed
* @param message the very bytes the Falcon seal of this commit signs * @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 * @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(); return List.of();
} }
try { try {
final Set<String> required = schedule.schemesAt(blockNumber); final Set<String> required = schedule.schemesRequiredForAnchor(blockNumber + 1L, anchorBlock);
final List<SchemeSeal> produced = new ArrayList<>(); final List<SchemeSeal> produced = new ArrayList<>();
for (final String schemeId : required) { for (final String schemeId : required) {
if (SealSchemes.FALCON_512.id().equals(schemeId)) { if (SealSchemes.FALCON_512.id().equals(schemeId)) {
@ -105,7 +204,11 @@ public final class HybridSealProducer {
+ "' which this binary does not implement"); + "' which this binary does not implement");
return List.of(); 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) { if (key == null) {
shoutOnce(schemeId, "this node holds NO " + schemeId shoutOnce(schemeId, "this node holds NO " + schemeId
+ " signing key, so it cannot produce the certificate the schedule requires from" + " signing key, so it cannot produce the certificate the schedule requires from"
@ -118,7 +221,7 @@ public final class HybridSealProducer {
return List.of(); return List.of();
} }
produced.add( 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 // Canonical order, so two honest nodes signing the same block emit identical bytes and the
// certificate cannot become a source of gratuitous divergence. // certificate cannot become a source of gratuitous divergence.

View File

@ -164,6 +164,26 @@ public final class HybridSealSupport {
throw new IllegalStateException( throw new IllegalStateException(
"AERE-PQC-HYBRID-CONF-04: unparseable " + PROPERTY_SCHEDULE + ": " + e.getMessage()); "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<String, Integer> 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; final HybridSignerRegistry registry;
try { try {
final Properties p = new Properties(); final Properties p = new Properties();
@ -253,6 +273,35 @@ public final class HybridSealSupport {
index); index);
} }
// AERE AIP-22 stage 3b-ii: this node's NEXT keys per scheme (aere.pq.hybrid.key.<scheme>.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<String, java.util.List<SealScheme.PrivateHandle>> 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()) { if (attachFrom != HybridSealProducer.NEVER && keys.isEmpty()) {
throw new IllegalStateException( throw new IllegalStateException(
"AERE-PQC-HYBRID-CONF-04: emission is armed from " + attachFrom + " but this node" "AERE-PQC-HYBRID-CONF-04: emission is armed from " + attachFrom + " but this node"
@ -263,7 +312,14 @@ public final class HybridSealSupport {
final HybridSealProducer producer = final HybridSealProducer producer =
keys.isEmpty() keys.isEmpty()
? HybridSealProducer.disarmed() ? 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); return new HybridSealSupport(schedule, registry, producer);
} }
@ -292,4 +348,36 @@ public final class HybridSealSupport {
public HybridSealProducer producer() { public HybridSealProducer producer() {
return 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<AerePqIdentity.Config> 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<SealScheme> 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.");
}
}
}
}
} }

View File

@ -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<Integer, Key> keys,
byte[] transport,
Map<String, byte[]> 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> 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<Row> rows,
List<Bytes32> 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<Row> 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<Row> 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<Integer, Key> 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<Bytes32> leaves) {
if (leaves.isEmpty()) {
throw new IllegalArgumentException("a registry has at least one row");
}
List<Bytes32> level = leaves;
while (level.size() > 1) {
final List<Bytes32> 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<Bytes32> merkleProof(final List<Bytes32> leaves, final int index) {
if (index < 0 || index >= leaves.size()) {
throw new IllegalArgumentException("index " + index + " is not a row of " + leaves.size());
}
final List<Bytes32> proof = new ArrayList<>();
List<Bytes32> 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<Bytes32> 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<Bytes32> 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<String> 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<String> 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<String> 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<String> 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<Row> 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<Bytes> ids = new HashSet<>();
final Set<Bytes> 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<Bytes32> hashes = new ArrayList<>();
for (final Row row : rows) {
hashes.add(rowHash(chainId, bindHeight, count, row));
}
for (final Row row : rows) {
final Optional<Row> 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<Bytes> 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<String> allowed = Set.of("id", "keys", "pop", "rotation", "ecdsa", "transport");
final Iterator<String> 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<Integer, Key> keys = new TreeMap<>();
final Iterator<Map.Entry<String, JsonNode>> it = keysNode.fields();
while (it.hasNext()) {
final Map.Entry<String, JsonNode> 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<String, byte[]> 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<Map.Entry<String, JsonNode>> ri = rot.fields();
while (ri.hasNext()) {
final Map.Entry<String, JsonNode> 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<Integer, Key> 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();
}
}

View File

@ -7,6 +7,17 @@
* Changing the mathematics of the chain becomes one property plus keys, never a code edit - * Changing the mathematics of the chain becomes one property plus keys, never a code edit -
* that is the whole point of the abstraction layer. * 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 * 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. * 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 * 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 com.google.common.base.Splitter;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
/** Immutable, validated height-to-scheme-set schedule. */ /** Immutable, validated height-to-scheme-set schedule. */
public final class PqSchemeSchedule { public final class PqSchemeSchedule {
/** One step: from {@code fromBlock} (inclusive) the anchor carries {@code schemeIds}. */ /**
public record Step(long fromBlock, Set<String> 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<String> schemeIds, Map<String, Integer> intervals) {
/** Canonical form: defensive copies, never null. */
public Step(
final long fromBlock, final Set<String> schemeIds, final Map<String, Integer> 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<String> schemeIds) {
this(fromBlock, schemeIds, Map.of());
}
}
private final List<Step> steps; private final List<Step> steps;
@ -40,8 +77,9 @@ public final class PqSchemeSchedule {
this.steps = steps; this.steps = steps;
} }
/** Parse "H:scheme[+scheme...],H:...". Refuses the WHOLE schedule on any defect: unknown or /** Parse "H:scheme[/interval][+scheme[/interval]...],H:...". Refuses the WHOLE schedule on any
* repeated scheme in a step, empty step, non-increasing heights, negative height, garbage. */ * 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) { public static PqSchemeSchedule parse(final String raw) {
if (raw == null || raw.isBlank()) { if (raw == null || raw.isBlank()) {
throw new IllegalArgumentException("AERE PQ ORAR-SCHEME: empty schedule"); throw new IllegalArgumentException("AERE PQ ORAR-SCHEME: empty schedule");
@ -74,12 +112,15 @@ public final class PqSchemeSchedule {
} }
lastHeight = h; lastHeight = h;
final Set<String> schemes = new LinkedHashSet<>(); final Set<String> schemes = new LinkedHashSet<>();
final Map<String, Integer> intervals = new LinkedHashMap<>();
for (final String id : Splitter.on('+').split(parti.get(1))) { for (final String id : Splitter.on('+').split(parti.get(1))) {
final String curat = id.trim(); final String piece = id.trim();
if (curat.isEmpty()) { if (piece.isEmpty()) {
throw new IllegalArgumentException( throw new IllegalArgumentException(
"AERE PQ ORAR-SCHEME: step at " + h + " carries an empty scheme name"); "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()) { if (SealSchemes.byId(curat).isEmpty()) {
throw new IllegalArgumentException( throw new IllegalArgumentException(
"AERE PQ ORAR-SCHEME: step at " + h + " names UNKNOWN scheme '" + curat "AERE PQ ORAR-SCHEME: step at " + h + " names UNKNOWN scheme '" + curat
@ -89,23 +130,41 @@ public final class PqSchemeSchedule {
throw new IllegalArgumentException( throw new IllegalArgumentException(
"AERE PQ ORAR-SCHEME: step at " + h + " repeats scheme '" + curat + "'"); "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()) { if (schemes.isEmpty()) {
throw new IllegalArgumentException( throw new IllegalArgumentException(
"AERE PQ ORAR-SCHEME: step at " + h + " has no schemes at all"); "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)); 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 /** The step in force at {@code height}, or null before the first one. */
* when the schedule has not started yet (= v2 not armed, legacy governs). */ private Step stepAt(final long height) {
public Set<String> schemesAt(final long height) { Step inForce = null;
Set<String> inForce = Set.of();
for (final Step s : steps) { for (final Step s : steps) {
if (s.fromBlock() <= height) { if (s.fromBlock() <= height) {
inForce = s.schemeIds(); inForce = s;
} else { } else {
break; break;
} }
@ -113,6 +172,57 @@ public final class PqSchemeSchedule {
return inForce; 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<String> 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<String> 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<String> 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. */ /** All steps, ascending. */
public List<Step> steps() { public List<Step> steps() {
return steps; return steps;

View File

@ -23,6 +23,7 @@ package org.hyperledger.besu.consensus.common.bft.blockcreation;
import static com.google.common.base.Preconditions.checkState; import static com.google.common.base.Preconditions.checkState;
import org.hyperledger.besu.config.BftConfigOptions; 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.ConsensusHelpers;
import org.hyperledger.besu.consensus.common.ForksSchedule; import org.hyperledger.besu.consensus.common.ForksSchedule;
import org.hyperledger.besu.consensus.common.bft.BftContext; import org.hyperledger.besu.consensus.common.bft.BftContext;
@ -119,7 +120,8 @@ public class BftBlockCreatorFactory<T extends BftConfigOptions> {
return new BftBlockCreator( return new BftBlockCreator(
miningConfiguration, miningConfiguration,
forksSchedule, forksSchedule,
localAddress, AerePqIdentity.local(
localAddress, protocolContext.getBlockchain().getChainHeadBlockNumber() + 1L),
ph -> createExtraData(round, ph), ph -> createExtraData(round, ph),
transactionPool, transactionPool,
protocolContext, protocolContext,
@ -186,7 +188,10 @@ public class BftBlockCreatorFactory<T extends BftConfigOptions> {
validatorProvider.getVoteProviderAfterBlock(parentHeader); validatorProvider.getVoteProviderAfterBlock(parentHeader);
checkState(voteProviderAfterBlock.isPresent(), "Bft requires a vote provider"); checkState(voteProviderAfterBlock.isPresent(), "Bft requires a vote provider");
final Optional<ValidatorVote> proposal = final Optional<ValidatorVote> proposal =
voteProviderAfterBlock.get().getVoteAfterBlock(parentHeader, localAddress); voteProviderAfterBlock
.get()
.getVoteAfterBlock(
parentHeader, AerePqIdentity.local(localAddress, parentHeader.getNumber() + 1L));
final List<Address> validators = final List<Address> validators =
new ArrayList<>(validatorProvider.getValidatorsAfterBlock(parentHeader)); new ArrayList<>(validatorProvider.getValidatorsAfterBlock(parentHeader));

View File

@ -16,6 +16,7 @@ package org.hyperledger.besu.consensus.common.bft.blockcreation;
import org.apache.tuweni.bytes.Bytes; import org.apache.tuweni.bytes.Bytes;
import org.hyperledger.besu.datatypes.Hash; 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.BftContext;
import org.hyperledger.besu.consensus.common.bft.BftExtraData; import org.hyperledger.besu.consensus.common.bft.BftExtraData;
import org.hyperledger.besu.consensus.common.bft.FalconSeal; import org.hyperledger.besu.consensus.common.bft.FalconSeal;
@ -380,8 +381,14 @@ public final class PqAnchorProducer {
falconIndices.add(f.getValidatorIndex()); falconIndices.add(f.getValidatorIndex());
} }
final HybridSealSupport hybrid = HybridSealSupport.instance(); 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<String> schemes = final Set<String> 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<SchemeSeal> heard = final List<SchemeSeal> heard =
PqSealCache.instance().extrasFor(parentNumber, parentHeader.getHash()); PqSealCache.instance().extrasFor(parentNumber, parentHeader.getHash());
for (final String schemeId : schemes) { for (final String schemeId : schemes) {
@ -397,7 +404,9 @@ public final class PqAnchorProducer {
"the scheme schedule names '" + schemeId + "', which this binary does not implement"); "the scheme schedule names '" + schemeId + "', which this binary does not implement");
} }
final Optional<HybridSignerRegistry> registry = hybrid.registry(); final Optional<HybridSignerRegistry> 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( throw new PqAnchorNotReadyException(
parentNumber, parentNumber,
0, 0,
@ -414,7 +423,10 @@ public final class PqAnchorProducer {
rejected++; rejected++;
continue; continue;
} }
final Optional<byte[]> pk = registry.get().publicKey(s.getValidatorIndex(), schemeId); final Optional<byte[]> pk =
v3Keys
? AerePqIdentity.v3KeyAt(parentNumber, s.getValidatorIndex(), schemeId)
: registry.get().publicKey(s.getValidatorIndex(), schemeId);
if (pk.isEmpty() if (pk.isEmpty()
|| !scheme.get().verifyRaw(pk.get(), message.toArray(), s.getSignature().toArray())) { || !scheme.get().verifyRaw(pk.get(), message.toArray(), s.getSignature().toArray())) {
rejected++; rejected++;

View File

@ -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 '<besu lib>/*' 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 '<besu lib>/*' ...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<String, String> o = new HashMap<>();
final List<String> 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<String, String> 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<String, String> 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<Integer, PqRegistryV3.Key> 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<String, String> o, final List<String> 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<String> 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<String, String> 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)");
}
}

View File

@ -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<String> SCHEMES = List.of("falcon-512", "slh-dsa-sha2-128s");
private PqRegistryV3.Registry a;
private PqRegistryV3.Registry b;
private TreeMap<Long, PqRegistryV3.Registry> 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));
}
}

View File

@ -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<String> 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<Long, PqRegistryV3.Registry> 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<Integer> 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<PqRegistryV3Test.Proba> 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<SchemeSeal> 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<SchemeSeal> 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<SchemeSeal> 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();
}
}

View File

@ -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<Address> addr = new ArrayList<>();
private final List<Address> 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<Long, PqRegistryV3.Registry> 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<Address> 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<Address> 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<Address> t = AerePqIdentity.translateSetAfter(set, H_ID - 1);
assertThat(t).containsExactlyElementsOf(new java.util.TreeSet<>(id));
}
@Test
public void aMemberWithoutARowRefusesInsteadOfBeingDropped() {
final Collection<Address> 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<Long, PqRegistryV3.Registry> epochs = new TreeMap<>();
epochs.put(epoch.bindHeight(), epoch);
assertThatThrownBy(
() -> AerePqIdentity.install(new AerePqIdentity.Config(epoch.bindHeight() - 1, epochs)))
.isInstanceOf(AerePqIdentity.IdentityException.class);
}
}

View File

@ -92,6 +92,35 @@ class HybridSealProducerTest {
assertThat(seals.get(0).getValidatorIndex()).isEqualTo(INDEX); 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 // ---------------------------------------------------------------- jumatatea de certificat
@Test @Test

View File

@ -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<String> 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<Bytes32> 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<Bytes32> 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<String> required(final JsonNode v) {
final List<String> 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<String, SealScheme.GeneratedPair> 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<String, SealScheme.GeneratedPair> 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<Integer, PqRegistryV3.Key> keys = new TreeMap<>();
final Iterator<Map.Entry<String, JsonNode>> it = row.get("keys").fields();
while (it.hasNext()) {
final Map.Entry<String, JsonNode> 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<ObjectNode> 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<String, SealScheme.GeneratedPair> both(final Proba p) {
final Map<String, SealScheme.GeneratedPair> 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<ObjectNode> 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<ObjectNode> 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<String, SealScheme.GeneratedPair> onlyF = new java.util.LinkedHashMap<>();
onlyF.put("falcon-512", v2.falcon());
final List<ObjectNode> 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<ObjectNode> 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<ObjectNode> 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<String, SealScheme.GeneratedPair> k = new java.util.LinkedHashMap<>();
k.put("falcon-512", v2.falcon());
k.put("slh-dsa-sha2-128s", v1.slh());
final List<ObjectNode> 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<ObjectNode> 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<ObjectNode> 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<ObjectNode> 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<ObjectNode> 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<ObjectNode> 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<SealScheme.GeneratedPair> 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();
}
}

View File

@ -116,6 +116,58 @@ class PqSchemeScheduleTest {
assertThat(orar.firstUnsatisfied(reg, 4)).isPresent(); 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 @Test
void beforeTheFirstStepMeansLegacyNotSomeDefaultScheme() { void beforeTheFirstStepMeansLegacyNotSomeDefaultScheme() {
final PqSchemeSchedule orar = PqSchemeSchedule.parse("500:falcon-512"); final PqSchemeSchedule orar = PqSchemeSchedule.parse("500:falcon-512");

View File

@ -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<Address> addr = new ArrayList<>();
final List<Address> 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<Long, PqRegistryV3.Registry> 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);
}
}

View File

@ -22,6 +22,7 @@
*/ */
package org.hyperledger.besu.consensus.qbft.core.validation; 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.ConsensusRoundIdentifier;
import org.hyperledger.besu.consensus.common.bft.payload.SignedData; import org.hyperledger.besu.consensus.common.bft.payload.SignedData;
import org.hyperledger.besu.consensus.qbft.core.messagewrappers.Commit; import org.hyperledger.besu.consensus.qbft.core.messagewrappers.Commit;
@ -207,7 +208,9 @@ public class CommitValidator {
} }
final Address commitSealCreator = final Address commitSealCreator =
Util.signatureToAddress(payload.getCommitSeal(), expectedCommitDigest); AerePqIdentity.author(
Util.signatureToAddress(payload.getCommitSeal(), expectedCommitDigest),
targetRound.getSequenceNumber());
if (!commitSealCreator.equals(signedPayload.getAuthor())) { if (!commitSealCreator.equals(signedPayload.getAuthor())) {
LOG.info( LOG.info(

View File

@ -18,6 +18,7 @@
*/ */
package org.hyperledger.besu.consensus.qbft.core.validation; 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.FalconSeal;
import org.hyperledger.besu.consensus.common.bft.HybridSealSupport; import org.hyperledger.besu.consensus.common.bft.HybridSealSupport;
import org.hyperledger.besu.consensus.common.bft.HybridSignerRegistry; import org.hyperledger.besu.consensus.common.bft.HybridSignerRegistry;
@ -113,6 +114,8 @@ public final class PqCommitEnforcement {
private final PqSchemeSchedule schemeSchedule; private final PqSchemeSchedule schemeSchedule;
private final HybridSignerRegistry hybridRegistry; private final HybridSignerRegistry hybridRegistry;
private final LongPredicate extrasRequiredAt; 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 * @param armedFromBlock first block height (inclusive) at which enforcement applies; use
@ -179,11 +182,34 @@ public final class PqCommitEnforcement {
final PqSchemeSchedule schemeSchedule, final PqSchemeSchedule schemeSchedule,
final HybridSignerRegistry hybridRegistry, final HybridSignerRegistry hybridRegistry,
final LongPredicate extrasRequiredAt) { 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.armedFromBlock = armedFromBlock;
this.registry = registry; this.registry = registry;
this.schemeSchedule = schemeSchedule; this.schemeSchedule = schemeSchedule;
this.hybridRegistry = hybridRegistry; this.hybridRegistry = hybridRegistry;
this.extrasRequiredAt = extrasRequiredAt; this.extrasRequiredAt = extrasRequiredAt;
this.anchorBlock = anchorBlock;
if ((schemeSchedule == null) != (hybridRegistry == null)) { if ((schemeSchedule == null) != (hybridRegistry == null)) {
// Half a hybrid configuration is the shape that starts a node believing it enforces // 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. // 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. * 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<String> hybridRefusal( private Optional<String> hybridRefusal(
final long height, final long height,
@ -250,7 +279,8 @@ public final class PqCommitEnforcement {
final Hash commitDigest, final Hash commitDigest,
final FalconSeal falconSeal, final FalconSeal falconSeal,
final List<SchemeSeal> extraSeals) { final List<SchemeSeal> extraSeals) {
final Set<String> required = schemeSchedule.schemesAt(height); final Set<String> required =
schemeSchedule.schemesRequiredForAnchor(height + 1L, anchorBlock.getAsLong());
for (final String schemeId : required) { for (final String schemeId : required) {
if (SealSchemes.FALCON_512.id().equals(schemeId)) { if (SealSchemes.FALCON_512.id().equals(schemeId)) {
continue; // already decided by the Falcon path above continue; // already decided by the Falcon path above
@ -284,7 +314,7 @@ public final class PqCommitEnforcement {
} }
final Optional<byte[]> bound = hybridRegistry.address(found.getValidatorIndex()); final Optional<byte[]> bound = hybridRegistry.address(found.getValidatorIndex());
if (bound.isEmpty() 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( return Optional.of(
"AERE HIBRID: index " + found.getValidatorIndex() "AERE HIBRID: index " + found.getValidatorIndex()
+ " is not bound to the commit author " + author + " in the hybrid registry"); + " is not bound to the commit author " + author + " in the hybrid registry");

View File

@ -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<Address> ids = new ArrayList<>();
@BeforeEach
public void registryForTheNodes() {
nodes = QbftNodeList.createNodes(N, qbftBlockCodec);
final long bind = 5;
final List<PqRegistryV3.Row> 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<Integer, PqRegistryV3.Key> 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<Bytes32> 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<Long, PqRegistryV3.Registry> 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();
}
}

View File

@ -190,6 +190,45 @@ public class PqHybridEnforcementTest {
.isEmpty(); .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<String> 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 // ============================================================ controalele negative
@Test @Test

View File

@ -14,6 +14,7 @@
*/ */
package org.hyperledger.besu.consensus.qbft.headervalidationrules; 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.BftContext;
import org.hyperledger.besu.consensus.common.bft.BftExtraData; import org.hyperledger.besu.consensus.common.bft.BftExtraData;
import org.hyperledger.besu.consensus.common.bft.FalconSeal; import org.hyperledger.besu.consensus.common.bft.FalconSeal;
@ -423,8 +424,21 @@ public class PqAnchorSealsRule implements AttachedBlockHeaderValidationRule {
return false; return false;
} }
final HybridSealSupport hybrid = HybridSealSupport.instance(); 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<String> namedSchemes = new HashSet<>();
final Set<String> requiredSchemes = new HashSet<>(); final Set<String> 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()); requiredSchemes.remove(SealSchemes.FALCON_512.id());
final Map<String, Integer> counted = new HashMap<>(); final Map<String, Integer> counted = new HashMap<>();
for (final SchemeSeal s : seals) { for (final SchemeSeal s : seals) {
@ -432,14 +446,14 @@ public class PqAnchorSealsRule implements AttachedBlockHeaderValidationRule {
continue; continue;
} }
final Optional<SealScheme> scheme = SealSchemes.byWireId(s.getSchemeWireId()); final Optional<SealScheme> scheme = SealSchemes.byWireId(s.getSchemeWireId());
if (scheme.isEmpty() || !requiredSchemes.contains(scheme.get().id())) { if (scheme.isEmpty() || !namedSchemes.contains(scheme.get().id())) {
LOG.warn( LOG.warn(
"AERE PQ ANCHOR (R2): block {} REJECTED - the v2 certificate carries a seal of scheme 0x{}," "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: {}).", + " which the scheme schedule does not name at the parent height {} (named: {}).",
number, number,
Integer.toHexString(s.getSchemeWireId() & 0xff), Integer.toHexString(s.getSchemeWireId() & 0xff),
parent.getNumber(), parent.getNumber(),
requiredSchemes); namedSchemes);
return false; return false;
} }
if (!falconIndices.contains(s.getValidatorIndex())) { if (!falconIndices.contains(s.getValidatorIndex())) {
@ -452,7 +466,10 @@ public class PqAnchorSealsRule implements AttachedBlockHeaderValidationRule {
return false; return false;
} }
final Optional<HybridSignerRegistry> registry = hybrid.registry(); final Optional<HybridSignerRegistry> 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( LOG.warn(
"AERE PQ ANCHOR (R2): block {} REJECTED - the certificate carries {} seals and this node" "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" + " 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()); scheme.get().id());
return false; return false;
} }
final Optional<byte[]> pk = registry.get().publicKey(s.getValidatorIndex(), scheme.get().id()); final Optional<byte[]> pk =
v3Keys
? AerePqIdentity.v3KeyAt(parent.getNumber(), s.getValidatorIndex(), scheme.get().id())
: registry.get().publicKey(s.getValidatorIndex(), scheme.get().id());
if (pk.isEmpty()) { if (pk.isEmpty()) {
LOG.warn( LOG.warn(
"AERE PQ ANCHOR (R2): block {} REJECTED - the hybrid registry holds no {} key for index {}.", "AERE PQ ANCHOR (R2): block {} REJECTED - the hybrid registry holds no {} key for index {}.",

View File

@ -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<NodeKey> keys = new ArrayList<>();
private final List<Address> addr = new ArrayList<>();
private final List<Address> ids = new ArrayList<>();
@BeforeEach
public void registry() {
final long bind = 10;
final List<PqRegistryV3.Row> 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<Integer, PqRegistryV3.Key> 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<Bytes32> 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<Long, PqRegistryV3.Registry> 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<Address> 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);
}
}

View File

@ -197,6 +197,40 @@ public class PqAnchorV2RulesTest {
// --------------------------------------------------------------------------------------- R2 // --------------------------------------------------------------------------------------- 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<SchemeSeal> 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 @Test
public void r2AcceptsTheHonestV2HeaderWithKSealsPerScheme() { public void r2AcceptsTheHonestV2HeaderWithKSealsPerScheme() {
final BlockHeader parent = parentHeader(V2 + 4L); final BlockHeader parent = parentHeader(V2 + 4L);