Prove that consensus safety does not depend on the transport
The network layer authenticates peers by their secp256k1 node key, and consensus messages are routed by the address derived from it. An adversary who recovers a validator's ECDSA key therefore gets everything the transport can give: it completes the handshake as that validator and signs syntactically perfect messages that recover to its address. This test models exactly that adversary - full ECDSA compromise, no Falcon key - and requires all four hot-path messages to be refused while the post-quantum enforcement is armed, with a harvested seal replayed across message kinds, blocks, heights, rounds, chains, and from another validator entirely. The honest form of the claim: the transport is not post-quantum, and breaking it does not buy a break of safety. It says nothing about liveness.
This commit is contained in:
parent
2ac6ecc316
commit
2b73bcb961
@ -110,6 +110,7 @@ bca46ca91803c89eb18d670c740edb4e47ef0bca53a9b4b82ce0c8446e0d6c0e consensus/qbft
|
|||||||
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
|
||||||
afbd5e820e5dabfbaa924be2730019cc133fcbd03095f9a8a4b783d8b5e79c90 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqProposalEnforcementTest.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
|
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
|
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
|
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
|
83dd971f66ed63103d09db5283240556bf1db1e1925c2a07808c7f59d47433ad consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/RoundChangeJustificationPqTest.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.
|
||||||
|
*
|
||||||
|
* <p><b>WHY THIS TEST IS THE ONE THAT MATTERS.</b> 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.
|
||||||
|
*
|
||||||
|
* <p>The question this test answers, by measurement rather than by argument: <b>with the
|
||||||
|
* post-quantum enforcement armed, does that adversary gain anything at all?</b> 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.
|
||||||
|
*
|
||||||
|
* <p><b>THE CONTROLS, without which the refusals would prove nothing:</b>
|
||||||
|
*
|
||||||
|
* <ol>
|
||||||
|
* <li>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.
|
||||||
|
* <li>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.
|
||||||
|
* <li>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.
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* <p><b>TWO OF THOSE CONTROLS EXIST BECAUSE THE NEGATIVE CONTROL CAUGHT THEIR ABSENCE</b>
|
||||||
|
* (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.
|
||||||
|
*
|
||||||
|
* <p><b>WHAT THIS DOES NOT PROVE, said plainly:</b> 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<Integer, Address> bindings = Map.of(0, VICTIM, 1, OTHER);
|
||||||
|
private final Map<Integer, byte[]> 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<String> 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<String> 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<String> refusal =
|
||||||
|
commitArmed().refusal(H, VICTIM, COMMIT_DIGEST, Optional.empty());
|
||||||
|
assertThat(refusal).isPresent();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aStolenECDSAKeyBuysNORoundChange() {
|
||||||
|
final Optional<String> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user