diff --git a/anchor/MANIFEST-sha256.txt b/anchor/MANIFEST-sha256.txt index 1105093..83f3177 100644 --- a/anchor/MANIFEST-sha256.txt +++ b/anchor/MANIFEST-sha256.txt @@ -110,6 +110,7 @@ bca46ca91803c89eb18d670c740edb4e47ef0bca53a9b4b82ce0c8446e0d6c0e consensus/qbft 1939e33cc8ea81782e5e17d68e2b59c001e4207ed8ef07acd1d2a166a047dcb3 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqPrepareEnforcementTest.java afbd5e820e5dabfbaa924be2730019cc133fcbd03095f9a8a4b783d8b5e79c90 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqProposalEnforcementTest.java 53a4e0246a55c239ed44a61271e6d38c7901e1101dfa53ebd44d7a7f5bcb74ac consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqRoundChangeEnforcementTest.java +54c98f0bb83a267b1e45ccb71f48d7bd97fc4e8dbd260b3be1dfa2e4f77a94f8 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqTransportIndependenceTest.java dc9f9e862a11f0135d26976176a1d1adac3e84fd5f1e06572d8727262cb3374f consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PrepareValidatorPqWiringTest.java 2c612868dfdd61d86cadc4ca9b34faacf4e43aca88878feb72ac140e1321ef5d consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/ProposalPayloadValidatorPqWiringTest.java 83dd971f66ed63103d09db5283240556bf1db1e1925c2a07808c7f59d47433ad consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/RoundChangeJustificationPqTest.java diff --git a/anchor/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqTransportIndependenceTest.java b/anchor/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqTransportIndependenceTest.java new file mode 100644 index 0000000..a276d30 --- /dev/null +++ b/anchor/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqTransportIndependenceTest.java @@ -0,0 +1,437 @@ +/* + * Copyright contributors to Besu. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.qbft.core.validation; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.hyperledger.besu.consensus.common.bft.ConsensusRoundIdentifier; +import org.hyperledger.besu.consensus.common.bft.FalconSeal; +import org.hyperledger.besu.consensus.common.bft.PqAnchor; +import org.hyperledger.besu.consensus.common.bft.PqSignerRegistry; +import org.hyperledger.besu.consensus.common.bft.SealScheme; +import org.hyperledger.besu.consensus.common.bft.SealSchemes; +import org.hyperledger.besu.consensus.qbft.core.payload.PreparedRoundMetadata; +import org.hyperledger.besu.crypto.SecureRandomProvider; +import org.hyperledger.besu.datatypes.Address; +import org.hyperledger.besu.datatypes.Hash; + +import java.security.SecureRandom; +import java.util.Map; +import java.util.Optional; + +import org.apache.tuweni.bytes.Bytes; +import org.apache.tuweni.bytes.Bytes32; +import org.junit.jupiter.api.Test; + +/** + * THE ADVERSARY WHO ALREADY HAS THE VALIDATOR'S ECDSA KEY, and still cannot move the chain. + * + *

WHY THIS TEST IS THE ONE THAT MATTERS. The network transport (RLPx/ECIES) authenticates + * a peer by its secp256k1 node key, and {@code ValidatorPeers} routes consensus messages by the + * address derived from that same key. A quantum adversary who recovers a validator's ECDSA key + * therefore gets EVERYTHING the transport can give: it completes the handshake as that validator, + * is routed consensus traffic as that validator, and signs syntactically perfect QBFT messages that + * recover to that validator's address. Every check that predates the post-quantum layers passes for + * it. + * + *

The question this test answers, by measurement rather than by argument: with the + * post-quantum enforcement armed, does that adversary gain anything at all? It models exactly + * that adversary - full ECDSA compromise, no Falcon key - and requires all four hot-path messages + * to be refused. That is what "consensus safety does not depend on the transport" means concretely, + * and it is the honest form of the claim: not that the transport is post-quantum (it is not), but + * that breaking it does not buy a break of safety. + * + *

THE CONTROLS, without which the refusals would prove nothing: + * + *

    + *
  1. DISARMED: the very same messages from the very same adversary are ACCEPTED when the + * enforcement is not armed. Without this, the refusals could come from any unrelated defect + * in the fixture and the test would be measuring its own mistake. + *
  2. THE HONEST VALIDATOR: a message carrying a VALID Falcon seal is accepted while armed. So + * the refusal is caused by the missing seal, not by arming per se - otherwise an enforcement + * that refuses everything would look identical to one that enforces. + *
  3. THE HARVESTED SEAL, in four flavours, because a broken transport hands the adversary the + * victim's entire message history to replay: a seal from another message KIND, one for + * another BLOCK at the same height and round, one from another HEIGHT, ROUND or CHAIN, and + * one belonging to ANOTHER VALIDATOR entirely, stapled onto the adversary's own message. + *
+ * + *

TWO OF THOSE CONTROLS EXIST BECAUSE THE NEGATIVE CONTROL CAUGHT THEIR ABSENCE + * (2026-08-31, {@code PROBA-CONTROL-INDEPENDENTA-TRANSPORT.sh}). The first version of this test + * passed with 8 green assertions, and stayed green under two planted defects: the digest removed + * from the signed preimage, and the index-to-author binding removed. Both were invisible because + * every replay it tried crossed a domain boundary, and because its registry held a single + * validator - so the two attacks a broken transport most directly enables could not even be + * expressed. Green, running, and blind. That is the whole reason a proof has to be shown red. + * + *

WHAT THIS DOES NOT PROVE, said plainly: nothing here is about LIVENESS. An adversary + * with a validator's ECDSA key can still occupy the connection slot, eclipse a peer, or flood it; + * those are denial-of-service questions and this test says nothing about them. It also says + * nothing about the fleet as configured today, where the enforcement heights are unset - the + * property proven is the property of the ARMED configuration. + */ +class PqTransportIndependenceTest { + + private static final long H = 1_000L; + private static final int ROUND = 2; + private static final long CHAIN_ID = 2800L; + + /** The validator whose ECDSA key the adversary has stolen. */ + private static final Address VICTIM = Address.fromHexString("0x" + "dd".repeat(20)); + + /** + * A SECOND, uncompromised validator. It exists because of what the negative control measured: + * with a single-validator registry, the scenario "the adversary staples someone else's seal - + * one it overheard on the broken wire - onto its own message" cannot even be expressed, and the + * planted removal of the index-to-author binding left the whole test GREEN. A test that cannot + * express the attack does not measure it. + */ + private static final Address OTHER = Address.fromHexString("0x" + "ee".repeat(20)); + + private static final Hash DIGEST = Hash.hash(Bytes.of(4, 2)); + + /** + * A DIFFERENT block digest at the same height and round. Also added after the negative control: + * every replay the first version tried crossed a DOMAIN boundary, so nothing required the + * digest itself to be under the seal - and the planted removal of the digest from the preimage + * went unnoticed. That removal is the difference between "this validator voted" and "this + * validator voted FOR THIS BLOCK". + */ + private static final Hash OTHER_DIGEST = Hash.hash(Bytes.of(7, 7)); + + private static final Hash COMMIT_DIGEST = Hash.hash(Bytes.of(4, 3)); + + private final SecureRandom random = SecureRandomProvider.createSecureRandom(); + + /** The victim's Falcon key pair - held by the HONEST node, never by the adversary. */ + private final SealScheme.GeneratedPair victimPq = SealSchemes.FALCON_512.generate(random); + + /** The second validator's Falcon key pair - likewise never in the adversary's hands. */ + private final SealScheme.GeneratedPair otherPq = SealSchemes.FALCON_512.generate(random); + + /** A registry binding index 0 to the victim and index 1 to the other validator. */ + private final PqSignerRegistry registry = + new PqSignerRegistry() { + private final Map bindings = Map.of(0, VICTIM, 1, OTHER); + private final Map keys = + Map.of(0, victimPq.publicRegistryForm(), 1, otherPq.publicRegistryForm()); + + @Override + public Address addressForIndexAtHistoric(final long blockNumber, final int validatorIndex) { + return bindings.get(validatorIndex); + } + + @Override + public Address addressForIndexAtOwnHead(final long blockNumber, final int validatorIndex) { + return bindings.get(validatorIndex); + } + + @Override + public boolean verifyAtHistoric( + final long blockNumber, final int validatorIndex, final Bytes message, final Bytes signature) { + return verifyAtOwnHead(blockNumber, validatorIndex, message, signature); + } + + @Override + public boolean verifyAtOwnHead( + final long blockNumber, final int validatorIndex, final Bytes message, final Bytes signature) { + final byte[] pk = keys.get(validatorIndex); + return pk != null + && SealSchemes.FALCON_512.verifyRaw(pk, message.toArray(), signature.toArray()); + } + }; + + /** A seal the HONEST victim would produce for the given message. */ + private FalconSeal honestSeal(final Bytes32 message) { + final byte[] sig = + SealSchemes.FALCON_512.sign(victimPq.privateKey(), message.toArray()).orElseThrow(); + return new FalconSeal(0, Bytes.wrap(sig)); + } + + /** + * A seal the OTHER honest validator would produce - the kind the adversary can hear on a broken + * wire and would like to reuse as its own. + */ + private FalconSeal otherValidatorSeal(final Bytes32 message) { + final byte[] sig = + SealSchemes.FALCON_512.sign(otherPq.privateKey(), message.toArray()).orElseThrow(); + return new FalconSeal(1, Bytes.wrap(sig)); + } + + private Bytes32 prepareMsg() { + return PqAnchor.prepareMessage(CHAIN_ID, H, ROUND, DIGEST.getBytes()); + } + + private Bytes32 proposalMsg() { + return PqAnchor.proposalMessage(CHAIN_ID, H, ROUND, DIGEST.getBytes()); + } + + /** + * The commit layer is the OLDEST of the four and its seal covers the commit digest DIRECTLY, + * without a domain-separated preimage - measured in {@code PqCommitEnforcement.falconRefusal}, + * not assumed. It is written out here rather than papered over: a test that signed a + * domain-separated message for commit would be testing a layer we do not have, and would fail + * for a reason that has nothing to do with the adversary being modelled. + */ + private Bytes32 commitMsg() { + return Bytes32.wrap(COMMIT_DIGEST.getBytes()); + } + + private Bytes32 roundChangeMsg() { + return PqAnchor.roundChangeMessage(CHAIN_ID, H, ROUND); + } + + // The four enforcements, armed at H. The adversary's messages recover to VICTIM, because the + // adversary holds VICTIM's ECDSA key: the author argument below is deliberately VICTIM. + private PqPrepareEnforcement prepareArmed() { + return new PqPrepareEnforcement(H, registry, CHAIN_ID); + } + + private PqProposalEnforcement proposalArmed() { + return new PqProposalEnforcement(H, registry, CHAIN_ID); + } + + private PqCommitEnforcement commitArmed() { + return new PqCommitEnforcement(H, registry); + } + + private PqRoundChangeEnforcement roundChangeArmed() { + return new PqRoundChangeEnforcement(H, registry, CHAIN_ID); + } + + // ----------------------------------------------------------------------------------------- + // THE MEASUREMENT: full ECDSA compromise, no Falcon key. All four messages refused. + // ----------------------------------------------------------------------------------------- + + @Test + void aStolenECDSAKeyBuysNOPrepare() { + final Optional refusal = + prepareArmed().refusal(H, ROUND, VICTIM, DIGEST, Optional.empty()); + assertThat(refusal) + .describedAs("an adversary holding the victim's ECDSA key must not be able to vote") + .isPresent(); + assertThat(refusal.get()).contains("carries NO post-quantum seal"); + } + + @Test + void aStolenECDSAKeyBuysNOProposal() { + final Optional refusal = + proposalArmed().refusal(H, ROUND, VICTIM, DIGEST, Optional.empty()); + assertThat(refusal).isPresent(); + assertThat(refusal.get()).contains("carries NO post-quantum seal"); + } + + @Test + void aStolenECDSAKeyBuysNOCommit() { + final Optional refusal = + commitArmed().refusal(H, VICTIM, COMMIT_DIGEST, Optional.empty()); + assertThat(refusal).isPresent(); + } + + @Test + void aStolenECDSAKeyBuysNORoundChange() { + final Optional refusal = + roundChangeArmed().refusal(H, ROUND, VICTIM, Optional.empty(), Optional.empty()); + assertThat(refusal).isPresent(); + assertThat(refusal.get()).contains("carries NO post-quantum seal"); + } + + // ----------------------------------------------------------------------------------------- + // CONTROL 1 - DISARMED: the very same messages pass. The refusals above are caused by the + // arming, not by something broken in this fixture. + // ----------------------------------------------------------------------------------------- + + @Test + void controlDISARMEDtheSameMessagesArePerfectlyValid() { + final long never = Long.MAX_VALUE; + assertThat( + new PqPrepareEnforcement(never, registry, CHAIN_ID) + .refusal(H, ROUND, VICTIM, DIGEST, Optional.empty())) + .isEmpty(); + assertThat( + new PqProposalEnforcement(never, registry, CHAIN_ID) + .refusal(H, ROUND, VICTIM, DIGEST, Optional.empty())) + .isEmpty(); + assertThat( + new PqCommitEnforcement(never, registry) + .refusal(H, VICTIM, COMMIT_DIGEST, Optional.empty())) + .isEmpty(); + assertThat( + new PqRoundChangeEnforcement(never, registry, CHAIN_ID) + .refusal(H, ROUND, VICTIM, Optional.empty(), Optional.empty())) + .isEmpty(); + } + + // ----------------------------------------------------------------------------------------- + // CONTROL 2 - THE HONEST VALIDATOR still works while armed. Otherwise "refuses everything" + // would be indistinguishable from "enforces". + // ----------------------------------------------------------------------------------------- + + @Test + void controlTheHONESTvalidatorPassesOnAllFourMessagesWhileArmed() { + assertThat( + prepareArmed().refusal(H, ROUND, VICTIM, DIGEST, Optional.of(honestSeal(prepareMsg())))) + .isEmpty(); + assertThat( + proposalArmed() + .refusal(H, ROUND, VICTIM, DIGEST, Optional.of(honestSeal(proposalMsg())))) + .isEmpty(); + assertThat(commitArmed().refusal(H, VICTIM, COMMIT_DIGEST, Optional.of(honestSeal(commitMsg())))) + .isEmpty(); + assertThat( + roundChangeArmed() + .refusal( + H, ROUND, VICTIM, Optional.empty(), Optional.of(honestSeal(roundChangeMsg())))) + .isEmpty(); + } + + // ----------------------------------------------------------------------------------------- + // CONTROL 3 - THE STOLEN SEAL. A broken transport lets the adversary READ every message the + // victim ever sent, so it can replay any seal it has seen. Each seal is bound to its own + // message, so a seal harvested from one message must not authenticate another. This is the + // control that turns "the adversary has no key" into "eavesdropping does not substitute for + // one" - and it is checked ACROSS message kinds (domain separation) and WITHIN a kind + // (a round-change seal replayed onto a claim of a prepared block). + // ----------------------------------------------------------------------------------------- + + @Test + void controlASealHARVESTEDfromAnotherMessageDoesNotAuthenticateThisOne() { + // a PREPARE seal, replayed on a PROPOSAL and on a ROUND-CHANGE + final FalconSeal harvestedPrepare = honestSeal(prepareMsg()); + assertThat(proposalArmed().refusal(H, ROUND, VICTIM, DIGEST, Optional.of(harvestedPrepare))) + .isPresent(); + assertThat( + roundChangeArmed() + .refusal(H, ROUND, VICTIM, Optional.empty(), Optional.of(harvestedPrepare))) + .isPresent(); + + // a PROPOSAL seal, replayed as a vote + final FalconSeal harvestedProposal = honestSeal(proposalMsg()); + assertThat(prepareArmed().refusal(H, ROUND, VICTIM, DIGEST, Optional.of(harvestedProposal))) + .isPresent(); + + // a bare ROUND-CHANGE seal, replayed on one that claims a prepared block: same kind, different + // assertion, and the metadata is in the preimage precisely so this fails + final FalconSeal harvestedRoundChange = honestSeal(roundChangeMsg()); + assertThat( + roundChangeArmed() + .refusal( + H, + ROUND, + VICTIM, + Optional.of(new PreparedRoundMetadata(DIGEST, 1)), + Optional.of(harvestedRoundChange))) + .isPresent(); + } + + // ----------------------------------------------------------------------------------------- + // CONTROL 3b - THE SEAL OF ANOTHER BLOCK, same kind, same height, same round. This is the + // difference between "this validator voted" and "this validator voted FOR THIS BLOCK", and it + // is the one an eavesdropper is best placed to exploit: on a broken wire it hears the victim's + // honest vote for block A and wants to turn it into a vote for block B. + // + // ADDED after the negative control caught its absence: every replay above crosses a DOMAIN + // boundary, so with the digest planted OUT of the signed message the whole test stayed GREEN. + // ----------------------------------------------------------------------------------------- + + @Test + void controlASealForANOTHERBLOCKDoesNotAuthenticateThisOne() { + final FalconSeal forOtherBlock = + honestSeal(PqAnchor.prepareMessage(CHAIN_ID, H, ROUND, OTHER_DIGEST.getBytes())); + assertThat(prepareArmed().refusal(H, ROUND, VICTIM, DIGEST, Optional.of(forOtherBlock))) + .describedAs("a vote for one block must not authenticate a vote for another") + .isPresent(); + + final FalconSeal proposalForOtherBlock = + honestSeal(PqAnchor.proposalMessage(CHAIN_ID, H, ROUND, OTHER_DIGEST.getBytes())); + assertThat(proposalArmed().refusal(H, ROUND, VICTIM, DIGEST, Optional.of(proposalForOtherBlock))) + .isPresent(); + + // and the commit layer, whose seal covers the commit digest directly + final FalconSeal commitForOtherBlock = honestSeal(Bytes32.wrap(OTHER_DIGEST.getBytes())); + assertThat(commitArmed().refusal(H, VICTIM, COMMIT_DIGEST, Optional.of(commitForOtherBlock))) + .isPresent(); + } + + // ----------------------------------------------------------------------------------------- + // CONTROL 3c - SOMEONE ELSE'S SEAL, stapled on. The adversary hears validator 1's perfectly + // valid seal for the very message it wants to send, and attaches it to its own message - which + // recovers to the VICTIM, because that is the ECDSA key it stole. The seal is valid, the + // signature is valid, the author is a real validator: only the index-to-author binding stands + // between that and a counted vote. + // + // ADDED after the negative control: with a single-validator registry this attack could not even + // be written, and the planted removal of that binding left the test GREEN. + // ----------------------------------------------------------------------------------------- + + @Test + void controlSOMEONEELSESsealDoesNotAuthenticateTheAdversarysMessage() { + final FalconSeal theirs = otherValidatorSeal(prepareMsg()); + + // sanity: that seal really is valid FOR ITS OWN AUTHOR, or the refusal below would prove + // nothing about the binding and everything about a broken fixture + assertThat(prepareArmed().refusal(H, ROUND, OTHER, DIGEST, Optional.of(theirs))) + .describedAs("validator 1's own seal must work for validator 1") + .isEmpty(); + + // the attack: the same valid seal, on a message authored with the victim's stolen key + assertThat(prepareArmed().refusal(H, ROUND, VICTIM, DIGEST, Optional.of(theirs))) + .describedAs("a seal must not vouch for a message someone else authored") + .isPresent(); + + // the same, on the other three surfaces + assertThat( + proposalArmed() + .refusal(H, ROUND, VICTIM, DIGEST, Optional.of(otherValidatorSeal(proposalMsg())))) + .isPresent(); + assertThat( + commitArmed() + .refusal(H, VICTIM, COMMIT_DIGEST, Optional.of(otherValidatorSeal(commitMsg())))) + .isPresent(); + assertThat( + roundChangeArmed() + .refusal( + H, + ROUND, + VICTIM, + Optional.empty(), + Optional.of(otherValidatorSeal(roundChangeMsg())))) + .isPresent(); + } + + // ----------------------------------------------------------------------------------------- + // CONTROL 4 - A SEAL FROM ANOTHER HEIGHT OR ROUND. The transport gives the adversary the + // victim's whole message history, so the seals it can replay are not only from other kinds but + // from other positions in the chain. + // ----------------------------------------------------------------------------------------- + + @Test + void controlASealFromAnotherHEIGHTorROUNDDoesNotAuthenticateThisOne() { + final FalconSeal otherHeight = + honestSeal(PqAnchor.prepareMessage(CHAIN_ID, H + 1, ROUND, DIGEST.getBytes())); + assertThat(prepareArmed().refusal(H, ROUND, VICTIM, DIGEST, Optional.of(otherHeight))) + .isPresent(); + + final FalconSeal otherRound = + honestSeal(PqAnchor.prepareMessage(CHAIN_ID, H, ROUND + 1, DIGEST.getBytes())); + assertThat(prepareArmed().refusal(H, ROUND, VICTIM, DIGEST, Optional.of(otherRound))) + .isPresent(); + + final FalconSeal otherChain = + honestSeal(PqAnchor.prepareMessage(CHAIN_ID + 1, H, ROUND, DIGEST.getBytes())); + assertThat(prepareArmed().refusal(H, ROUND, VICTIM, DIGEST, Optional.of(otherChain))) + .isPresent(); + } +}