The proposal now carries an optional post-quantum seal, disarmed by default
The remaining hot-path gap, stated precisely because the imprecise version oversells: with PREPARE enforcement armed, an ECDSA-breaking adversary cannot finalize anything, since "prepared" needs a full quorum of PREPAREs. What forged proposals could still do is OPEN rounds and waste them. With proposal enforcement armed, a proposal without a valid Falcon seal from its own proposer does not open a round. Same pattern as the PREPARE layer, deliberately: an optional fifth payload element that leaves the unsealed encoding byte-identical to upstream; a third, separate emission gate; enforcement self-wired in the payload validator so no refactor can drop it silently; its own domain over (chainId, height, round, digest), tested in both directions so an honest offer seal cannot be replayed as a vote nor a vote seal as an offer. Nothing is armed anywhere: the properties are unset, and unset means never. The armed- layers table in the README says so, and says what stays classical: the ROUND-CHANGE message itself and node-level devp2p authentication. 1,042 tests, 0 failures, counted from the XML. The enforcement's negative control ran at 5 of 5: four planted defects each turned the suite red, the untouched source stayed green.
This commit is contained in:
parent
4ef6565275
commit
3afe906fb2
@ -13,11 +13,11 @@ a3b93f2602e9755d91358c9b3f473235f4d40ae23d912f797cdb03ed7e1348f9 consensus/comm
|
||||
e32a03de9f1452bd7444a33b084516ae3399d7a5ae7430088714f5656d3a22ea consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/BftExtraData.java
|
||||
3ab425b5b5f7d7c2199065a3905623d5c1c4c5922d892c6c4275415b0c69a2b4 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/FalconSeal.java
|
||||
db1e80115ce59c8281fbf046cb37e153a49fe017c1675afd200e8125d3f6b538 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/FalconSealScheme.java
|
||||
167af0d87b017e1e15ecd2105426b009b6bdaeec5b52f7c1f710d212b0330765 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/FalconSealSupport.java
|
||||
4b6de4abba87246b18c26fb18a65dfc68831aa981dffefe15eeb2c075fdfcf0f consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/FalconSealSupport.java
|
||||
e8c8111a343cc993b950ac4e5b3992f16b8ee3d34c8827f00dd80d017884335b consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/HybridSealProducer.java
|
||||
03043d2360c35de3b6f27d029b7ee83cb6c964f4b827a504799607b8bfe1cdcc consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/HybridSealSupport.java
|
||||
65e33e639c58adb30045e88c770460757305acab31ac884e4c6b36f1c410ef12 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/HybridSignerRegistry.java
|
||||
e9b30713dea69601dd29b3e460d81585a21f5c48286d18f4d3bc5d4c4ec83c19 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchor.java
|
||||
afe13adaa495c4d0a9ada14df555afcbc6c8d46d8883730ab1ce201499eebc5f consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchor.java
|
||||
852113d420f2c6b82945423a18591c3b2bd1018febe1a52e8b4e42a996c85114 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorConfig.java
|
||||
c6285ca43331781e1d350a07d58256d02b8b3a74b96b6469c0dca33ecfb6c8cd consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorLapse.java
|
||||
f27952a25bad02d2939a356f1bac8800ff8fb08023c6bdfb1c6d0e51b5a206da consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorNotReadyException.java
|
||||
@ -81,23 +81,28 @@ e0bc7a465dbb2dd7efa5f1830211a1a45c6554c300b2088dd70bb9677b0ac217 consensus/comm
|
||||
9a371ec367cda846640c6da18c8bf7d921a6882cfc024dc0c86d4f965cae0c63 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqStartupHistoryTest.java
|
||||
e65ba92e288aebe768909b1fee5c0b8850be74b3658c6242e6abdb91409a9521 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqV2Fixture.java
|
||||
56426ba6bb10df29f5ef5f5959b1646dda5a6d9e7db9904ceb7ad1070ad750b8 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PreparePqAttachGateTest.java
|
||||
e355b147fdda48c14cb6dc09b1bc1e7a7ccae3f9b709af8f8483f896086dbe4d consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/ProposalPqAttachGateTest.java
|
||||
d48d62c6ea0784864a5987a804a28855265b2180c409ba3c5b4434d21b805e1d consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/SealSchemeAgilityTest.java
|
||||
22ade1aa2d8254d1f7ff02bbbcb4d02155546a52fca3421b61eea1129b73ee48 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/messagewrappers/Commit.java
|
||||
046c67d18ba623cd01390b6ffef945b0dfa245fbf0cc79889b7670ab465b7992 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/network/QbftMessageTransmitter.java
|
||||
03173f2029f767e10cc978b5576b9557efa783dab2ef105aa856f6d5e08b9352 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/CommitPayload.java
|
||||
37bda0f126c1333cf854a33c64cc98cedcdc8c0c2ec4cc3ba1f6736e7302ddff consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/MessageFactory.java
|
||||
e577fe2c90ac2cf8bff2145376f44284cad1562c7d09b2eb6da9899849cdd882 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/MessageFactory.java
|
||||
861a2e6f81df1c33755f28ca50bf88b6b0b197f7a7a1d646c165788d192160ca consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/PreparePayload.java
|
||||
428e11639fb24ae4b04972457714445a5cd59d7946135a8b7362123461fa690f consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/ProposalPayload.java
|
||||
64c3ab6f9b67eb2ed126bca89ffc0af49bebc17bc8a41b56efc19e51188ca75d consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/QbftBlockHeightManager.java
|
||||
252f9a86ca17cc6362780af264dbc4da4d842191467427a809ff1e4baa7640af consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/QbftController.java
|
||||
07834160f12c4f28c567959c358176708b17742cc7cc4f49542b165efef5f1c8 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/QbftRound.java
|
||||
a56a86a95e3ff7de3c9b22ee6e91c88ed713a3c065cadbb3b6fe205698a4c2c6 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/QbftRound.java
|
||||
a22d857867c5b7c9ccdf9185c661870eafe6b481573189202b2cc34d9fbf3e2d consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/RoundState.java
|
||||
601b0cccf32f8ebe327c7581e0e9b3f9f67de49d9fb646326d6b49ef2138c641 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/types/QbftBlockCreator.java
|
||||
7f9fb13a7bbe3015bbef6329f750f2b35fb3744895cd3e6599af90450fab6a73 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/CommitValidator.java
|
||||
ec53f8e401a19345a067223f482f27a95cb9c3153d5bee0bfea9fd622bc0db01 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/PqCommitEnforcement.java
|
||||
5ab52433f770537e838b15e23abfc7b24eaa733ea26577451d5e32226d617879 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/PqPrepareEnforcement.java
|
||||
742611d52091a6c138cdff60c5f7f0bfb2fc33e567f520f3e86878fc086e77d9 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/PqProposalEnforcement.java
|
||||
60eb9c5a8e3f464aa42915909e402bf59af9bad4e28631538dcf2f5ca52a9569 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/PrepareValidator.java
|
||||
8ab72d5558842779a6df6d5d4d7c06479ac3a19746b51bdd00d5168084f0b590 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/ProposalPayloadValidator.java
|
||||
36dd77cdea980e4de0090e86ae2ca75d1af62a9477fe056c7fdde06422617898 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/payload/CommitPayloadHybridTest.java
|
||||
fe25255de4b5fa6d86412fa4c48218180f549f0a4ffea5b5cc0a7d19a5be4cd9 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/payload/PreparePayloadPqTest.java
|
||||
a994d5638940ea90f37ed6f3ed5eebc24781707f2f4555c7a079d1251ddbf893 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/payload/ProposalPayloadPqTest.java
|
||||
0bb2c1e31a7f29e9b07fb7030567746fc6d79460ed4739d5ecafac44501f7bb5 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/statemachine/PqLateSealSalvageTest.java
|
||||
8c40173c7b248edec7c29ee9e049f9e269f4725b637bceb8734735fc73eb9ca2 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/CommitValidatorPqEnforcementTest.java
|
||||
401c25f63abb248dfe60b69676a34ea255bb669b4bc58e45916f6eecea3cb870 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqCommitEnforcementTest.java
|
||||
@ -105,7 +110,9 @@ c0853ed97c53d54951e25ad6d0b70c0299dedd859cc7c44da64e3751d0e0de33 consensus/qbft
|
||||
5e0bb0ecc77ffb06f232e1aa81cca6870e5455af7c2846ae54d5f477f1bbb88b consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqHybridEnforcementTest.java
|
||||
5a47d247d7bcda77b57f5c906c3cee1af826785416012fafe9cfa49d63671388 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqPrepareAgilityTest.java
|
||||
1939e33cc8ea81782e5e17d68e2b59c001e4207ed8ef07acd1d2a166a047dcb3 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqPrepareEnforcementTest.java
|
||||
afbd5e820e5dabfbaa924be2730019cc133fcbd03095f9a8a4b783d8b5e79c90 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqProposalEnforcementTest.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
|
||||
79dea5e85b968af696bf57d51d0de175a4c025537200402e3594a4a8290d1a29 consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/QbftBlockHeaderValidationRulesetFactory.java
|
||||
7d0a75818ac64b601b79c32b8289eedda8e2300dbb0f5e9186084f4e01b04d26 consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/QbftExtraDataCodec.java
|
||||
|
||||
@ -147,6 +147,8 @@ This matters more than the file list, so it is stated before it.
|
||||
| Legacy per-block Falcon rule (`aere.falcon.forkBlock`) | yes | **no** — retired at the anchor block |
|
||||
| Post-quantum seal on PREPARE, emission | yes | **no** — no node sets the property |
|
||||
| Post-quantum seal on PREPARE, enforcement | yes | **no** — no node sets the property |
|
||||
| Post-quantum seal on the PROPOSAL, emission | yes | **no** — no node sets the property |
|
||||
| Post-quantum seal on the PROPOSAL, enforcement | yes | **no** — no node sets the property |
|
||||
| Hybrid Falcon + SLH-DSA certificate | yes | **no** — needs new keys, not generated |
|
||||
|
||||
Everything in the "no" rows is **disarmed by absence, not by a flag**: the properties that arm them
|
||||
@ -160,6 +162,16 @@ unsealed PREPAREs never reaches the prepared state, so it never sends COMMIT at
|
||||
it has no safety net during an activation, which is why it is not armed anywhere and why its
|
||||
activation height is a decision that has not been taken.
|
||||
|
||||
The PROPOSAL layer (added 2026-08-30) closes the remaining hot-path gap, and its claim is stated
|
||||
precisely because the imprecise version oversells: an adversary who breaks ECDSA cannot finalize
|
||||
anything while the PREPARE layer is armed, since "prepared" needs a full quorum of PREPAREs. What
|
||||
forged proposals could still do is OPEN rounds and waste them. With proposal enforcement armed, a
|
||||
proposal without a valid Falcon seal from its own proposer does not open a round. The seal signs
|
||||
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. What remains classical: the
|
||||
ROUND-CHANGE message itself (its embedded justifications are already coupled to PREPARE
|
||||
enforcement) and node-level devp2p authentication.
|
||||
|
||||
### The files
|
||||
|
||||
- `consensus/common/.../bft/` — the anchor itself: configuration, the digest, the seal cache and
|
||||
|
||||
@ -136,10 +136,14 @@ public final class FalconSealSupport {
|
||||
private static final String ANCHOR_SLOT =
|
||||
"0000000000000000000000000000000000000000000000000000000000000000";
|
||||
|
||||
/** Cate sigilii a emis acest nod pe PREPARE. Vezi {@link #preparesSealed()}. */
|
||||
/** How many seals this node emitted on its own PREPAREs. See {@link #preparesSealed()}. */
|
||||
private final java.util.concurrent.atomic.AtomicLong preparesSealed =
|
||||
new java.util.concurrent.atomic.AtomicLong();
|
||||
|
||||
/** How many seals this node emitted on its own PROPOSALs since startup. */
|
||||
private final java.util.concurrent.atomic.AtomicLong proposalsSealed =
|
||||
new java.util.concurrent.atomic.AtomicLong();
|
||||
|
||||
private final boolean signingEnabled;
|
||||
private final int localIndex;
|
||||
private final FalconPrivateKeyParameters localPrivateKey;
|
||||
@ -3465,9 +3469,10 @@ public final class FalconSealSupport {
|
||||
* Property naming the first height at which this node ATTACHES a post-quantum seal to its own
|
||||
* PREPARE messages. Absent = never, which is the configuration of every node today.
|
||||
*
|
||||
* <p>SEPARAT de {@code aere.falcon.attachBlock}, si separarea e obligatorie: daca emiterea pe
|
||||
* PREPARE ar porni odata cu cea pe commit, ridicarea binarului pe flota ar deveni o zi de flag.
|
||||
* Asa, binarul poate sta luni de zile pe toate nodurile inainte ca vreunul sa emita ceva nou.
|
||||
* <p>SEPARATE from {@code aere.falcon.attachBlock}, and the separation is mandatory: if PREPARE
|
||||
* emission started together with commit emission, rolling the binary onto the fleet would become
|
||||
* a flag day. This way the binary can sit on every node for months before any of them emits
|
||||
* anything new.
|
||||
*/
|
||||
public static final String PREPARE_ATTACH_PROPERTY = "aere.pq.preparePq.attachBlock";
|
||||
|
||||
@ -3576,6 +3581,106 @@ public final class FalconSealSupport {
|
||||
return preparesSealed.get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Property naming the first height at which this node ATTACHES a post-quantum seal to its own
|
||||
* PROPOSAL messages. Absent = never, which is the configuration of every node today.
|
||||
*
|
||||
* <p>SEPARATE from both the commit and the PREPARE gates, same reason: three layers that arm on
|
||||
* three independent decisions must not share a switch, or the day one is turned on becomes a flag
|
||||
* day for the others.
|
||||
*/
|
||||
public static final String PROPOSAL_ATTACH_PROPERTY = "aere.pq.proposalPq.attachBlock";
|
||||
|
||||
/** Environment fallback for {@link #PROPOSAL_ATTACH_PROPERTY}. */
|
||||
public static final String PROPOSAL_ATTACH_ENV = "AERE_PQ_PROPOSALPQ_ATTACHBLOCK";
|
||||
|
||||
/**
|
||||
* The configured PROPOSAL attachment height, read fresh on every call.
|
||||
*
|
||||
* <p>Absent = {@link Long#MAX_VALUE}, i.e. never. A value that is PRESENT but unreadable REFUSES
|
||||
* loudly instead of disarming - the anchor loader's lesson, once per gate, every gate.
|
||||
*
|
||||
* @return the height, or Long.MAX_VALUE when unset
|
||||
*/
|
||||
public static long proposalAttachBlock() {
|
||||
final String raw = resolve(PROPOSAL_ATTACH_PROPERTY, PROPOSAL_ATTACH_ENV);
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return Long.MAX_VALUE;
|
||||
}
|
||||
try {
|
||||
final long v = Long.parseLong(raw.trim());
|
||||
if (v < 0) {
|
||||
throw new NumberFormatException("negative");
|
||||
}
|
||||
return v;
|
||||
} catch (final NumberFormatException e) {
|
||||
throw new ActivationConfigException(
|
||||
ActivationConfigException.Kind.SYNTAX,
|
||||
"AERE-PQC-PROPOSAL-CONF-01",
|
||||
"AERE PQ PROPOSAL: "
|
||||
+ PROPOSAL_ATTACH_PROPERTY
|
||||
+ " is set to '"
|
||||
+ raw
|
||||
+ "', which is not a non-negative block height. A node must REFUSE to start rather "
|
||||
+ "than silently run with PROPOSAL attachment disarmed: a disarmed node looks exactly "
|
||||
+ "like a correctly configured one until the day it matters.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign this node's own PROPOSAL, when the PROPOSAL attachment gate is open at this height.
|
||||
*
|
||||
* <p>Same three requirements as {@link #signPrepare}, for the same three reasons: a key, the
|
||||
* commit-layer coverage conditions, and its own height. NEVER throws except for the strict
|
||||
* configuration case: a signing failure is a log line and an empty value, because the ECDSA path
|
||||
* must not be disturbed.
|
||||
*
|
||||
* @param blockNumber the height being proposed
|
||||
* @param message the domain-separated PROPOSAL message (see PqAnchor.proposalMessage)
|
||||
* @return the seal, or empty when any gate is shut
|
||||
*/
|
||||
public Optional<FalconSeal> signProposal(final long blockNumber, final Bytes32 message) {
|
||||
if (!signingEnabled) {
|
||||
return Optional.empty();
|
||||
}
|
||||
if (!attachmentArmed(blockNumber)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
if (blockNumber < proposalAttachBlock()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
final java.util.Optional<byte[]> sig =
|
||||
((FalconSealScheme) SealSchemes.FALCON_512).signWithParams(localPrivateKey, message.toArray());
|
||||
if (sig.isEmpty()) {
|
||||
LOG.warn("AERE PQ PROPOSAL: Falcon signing failed (ECDSA path unaffected)");
|
||||
return Optional.empty();
|
||||
}
|
||||
final long n = proposalsSealed.incrementAndGet();
|
||||
// Same log-volume decision as at PREPARE: a proposal is at most one per round per proposer, so
|
||||
// this is quieter by nature, but the counter still steps so a testnet has a number to count.
|
||||
if (n == 1L) {
|
||||
LOG.info(
|
||||
"AERE PQ PROPOSAL: this node EMITTED its first post-quantum seal on a PROPOSAL, at "
|
||||
+ "height {} (gate {}={}). From here on its proposals carry a seal.",
|
||||
blockNumber,
|
||||
PROPOSAL_ATTACH_PROPERTY,
|
||||
proposalAttachBlock());
|
||||
} else if (n % 500L == 0L) {
|
||||
LOG.info("AERE PQ PROPOSAL: {} seals emitted on PROPOSALs since startup.", n);
|
||||
}
|
||||
return Optional.of(new FalconSeal(localIndex, Bytes.wrap(sig.get())));
|
||||
}
|
||||
|
||||
/**
|
||||
* How many seals this node has emitted on its own PROPOSALs since startup. Exists so the
|
||||
* coverage step can be measured, exactly as at PREPARE.
|
||||
*
|
||||
* @return the count
|
||||
*/
|
||||
public long proposalsSealed() {
|
||||
return proposalsSealed.get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a Falcon seal against the registry public key for its validator index. Never throws.
|
||||
*
|
||||
|
||||
@ -102,10 +102,10 @@ public final class PqAnchor {
|
||||
* The PREPARE domain label. AERE PQ (2026-08-28).
|
||||
*
|
||||
* <p>SEPARATE FROM COMMIT, and the separation is a security requirement, not a matter of style.
|
||||
* If a PREPARE seal signed the same bytes as a commit seal, an adversary could take the PREPARE
|
||||
* dat CINSTIT de un validator si sa il lipeasca pe un COMMIT falsificat: semnatura ar verifica,
|
||||
* si chiar regula pusa sa apere commitul ar fi ocolita. Un singur sir schimbat in preimagine face
|
||||
* cele doua semnaturi netransferabile.
|
||||
* If a PREPARE seal signed the same bytes as a commit seal, an adversary could take a PREPARE
|
||||
* seal given HONESTLY by a validator and paste it onto a forged COMMIT: the signature would
|
||||
* verify, and the very rule meant to defend the commit would be bypassed. One changed string in
|
||||
* the preimage makes the two signatures non-transferable.
|
||||
*/
|
||||
public static final String PREPARE_DOMAIN = "AERE-PQ-PREPARE-1";
|
||||
|
||||
@ -113,6 +113,21 @@ public final class PqAnchor {
|
||||
public static final Bytes PREPARE_DOMAIN_BYTES =
|
||||
Bytes.wrap(PREPARE_DOMAIN.getBytes(StandardCharsets.US_ASCII));
|
||||
|
||||
/**
|
||||
* The PROPOSAL domain label. AERE PQ (2026-08-30), the next hot-path step after PREPARE.
|
||||
*
|
||||
* <p>SEPARATE FROM BOTH PREPARE AND COMMIT, for the same non-transferability reason: a seal a
|
||||
* proposer gives HONESTLY over its own proposal must not be usable as a vote. A proposal is an
|
||||
* OFFER, not a vote - the design note of 2026-08-28 measures that "prepared" needs a full quorum
|
||||
* of PREPAREs, proposer included - so its seal must never count as one. One changed string in the
|
||||
* preimage is what enforces that at the cryptographic layer instead of by convention.
|
||||
*/
|
||||
public static final String PROPOSAL_DOMAIN = "AERE-PQ-PROPOSAL-1";
|
||||
|
||||
/** The proposal domain label as raw bytes. */
|
||||
public static final Bytes PROPOSAL_DOMAIN_BYTES =
|
||||
Bytes.wrap(PROPOSAL_DOMAIN.getBytes(StandardCharsets.US_ASCII));
|
||||
|
||||
/** Orders Falcon seals by their registry index, ascending. */
|
||||
public static final Comparator<FalconSeal> BY_INDEX =
|
||||
Comparator.comparingInt(FalconSeal::getValidatorIndex);
|
||||
@ -237,8 +252,8 @@ public final class PqAnchor {
|
||||
*
|
||||
* <p>RUNDA E IN PREIMAGINE, spre deosebire de commit, si asta e al doilea lucru care nu se sare:
|
||||
* doua PREPARE-uri ale aceluiasi bloc in runde diferite sunt doua afirmatii diferite, iar un
|
||||
* a seal given in one round must not be movable into another. Without the round, a seal from
|
||||
* PREPARE dintr-o runda esuata ar putea fi refolosit ca sa justifice o alta.
|
||||
* a seal given in one round must not be movable into another. Without the round, a seal from a
|
||||
* PREPARE of a failed round could be reused to justify another one.
|
||||
*
|
||||
* @param chainId the chain id
|
||||
* @param blockNumber the height being prepared
|
||||
@ -273,6 +288,46 @@ public final class PqAnchor {
|
||||
return Hash.keccak256(out.encoded());
|
||||
}
|
||||
|
||||
/**
|
||||
* The 32-byte message a proposer's post-quantum PROPOSAL seal signs.
|
||||
*
|
||||
* <p>Same shape as {@link #prepareMessage}, different domain, and the ROUND is in the preimage
|
||||
* for the same reason: a proposal for the same block in a different round is a different
|
||||
* assertion, and a seal from a failed round must not open another one.
|
||||
*
|
||||
* @param chainId the chain id
|
||||
* @param blockNumber the height being proposed
|
||||
* @param round the round number of the proposal
|
||||
* @param digest the digest of the proposed block
|
||||
* @return the 32-byte message to sign
|
||||
*/
|
||||
public static Bytes32 proposalMessage(
|
||||
final long chainId, final long blockNumber, final int round, final Bytes digest) {
|
||||
if (blockNumber < 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"AERE PQ PROPOSAL: blockNumber must not be negative (got " + blockNumber + ")");
|
||||
}
|
||||
if (round < 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"AERE PQ PROPOSAL: round must not be negative (got " + round + ")");
|
||||
}
|
||||
if (digest == null || digest.size() != 32) {
|
||||
throw new IllegalArgumentException(
|
||||
"AERE PQ PROPOSAL: digest must be 32 bytes (got "
|
||||
+ (digest == null ? "null" : digest.size() + " bytes")
|
||||
+ ")");
|
||||
}
|
||||
final BytesValueRLPOutput out = new BytesValueRLPOutput();
|
||||
out.startList();
|
||||
out.writeBytes(PROPOSAL_DOMAIN_BYTES);
|
||||
out.writeLongScalar(chainId);
|
||||
out.writeLongScalar(blockNumber);
|
||||
out.writeLongScalar(round);
|
||||
out.writeBytes(digest);
|
||||
out.endList();
|
||||
return Hash.keccak256(out.encoded());
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the certificate's validator indices are STRICTLY increasing.
|
||||
*
|
||||
|
||||
@ -0,0 +1,104 @@
|
||||
/*
|
||||
* Copyright contributors to Besu.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
|
||||
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations under the License.
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
package org.hyperledger.besu.consensus.common.bft;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import org.apache.tuweni.bytes.Bytes32;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* THE GATE that emits a seal on the PROPOSAL (AERE PQ, 2026-08-30). Twin of the PREPARE gate,
|
||||
* with the same shape on purpose.
|
||||
*
|
||||
* <p>What is measured here is the CONFIGURATION SURFACE, which is exactly the part that gets typed
|
||||
* by hand and therefore mistyped: absent means never, a good value means from that height onwards,
|
||||
* and a MISTYPED value refuses loudly instead of booting the node disarmed. The lesson paid for in
|
||||
* the anchor loader is that a stray character must never disarm silently, because then nobody finds
|
||||
* out.
|
||||
*
|
||||
* <p>What is NOT measured here, and it is said plainly: that an ARMED node actually produces a
|
||||
* seal. That needs a Falcon key and a registry bound to addresses, which means a network; it is
|
||||
* measured at the coverage step, on a testnet. What is proven here is that the gate is closed by
|
||||
* default and cannot be opened by accident.
|
||||
*/
|
||||
class ProposalPqAttachGateTest {
|
||||
|
||||
@AfterEach
|
||||
void clearTheProperty() {
|
||||
System.clearProperty(FalconSealSupport.PROPOSAL_ATTACH_PROPERTY);
|
||||
}
|
||||
|
||||
@Test
|
||||
void withoutThePropertyTheGateIsClosedForever() {
|
||||
assertThat(FalconSealSupport.proposalAttachBlock()).isEqualTo(Long.MAX_VALUE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aGoodValueIsReadAsGiven() {
|
||||
System.setProperty(FalconSealSupport.PROPOSAL_ATTACH_PROPERTY, "16500000");
|
||||
assertThat(FalconSealSupport.proposalAttachBlock()).isEqualTo(16_500_000L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void zeroIsALEGALValue() {
|
||||
// A threshold of zero means "from genesis", and that is a legitimate configuration on a
|
||||
// testnet. Treated as "unset", a correctly configured testnet would run disarmed in silence.
|
||||
System.setProperty(FalconSealSupport.PROPOSAL_ATTACH_PROPERTY, "0");
|
||||
assertThat(FalconSealSupport.proposalAttachBlock()).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void aMISTYPEDValueRefusesLoudly() {
|
||||
for (final String bad : new String[] {"nu-e-numar", "16_500_000", "1e6", "-1", " "}) {
|
||||
System.setProperty(FalconSealSupport.PROPOSAL_ATTACH_PROPERTY, bad);
|
||||
if (bad.isBlank()) {
|
||||
// whitespace is "unset", not a mistyped value: an empty field in a configuration file
|
||||
// must not stop a node
|
||||
assertThat(FalconSealSupport.proposalAttachBlock()).isEqualTo(Long.MAX_VALUE);
|
||||
continue;
|
||||
}
|
||||
assertThatThrownBy(FalconSealSupport::proposalAttachBlock)
|
||||
.as("the value '%s'", bad)
|
||||
.isInstanceOf(FalconSealSupport.ActivationConfigException.class)
|
||||
.hasMessageContaining("AERE-PQC-PROPOSAL-CONF-01");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void withNoKeyNothingIsSignedEvenWithTheGateOpen() {
|
||||
// The gate is open from genesis and still nothing comes out: the node has no Falcon key. That
|
||||
// is precisely the condition that makes the binary safe to roll onto the fleet before any
|
||||
// decision is taken.
|
||||
System.setProperty(FalconSealSupport.PROPOSAL_ATTACH_PROPERTY, "0");
|
||||
assertThat(FalconSealSupport.instance().signProposal(1L, Bytes32.ZERO)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void thePROPOSALGateIsNotThePREPAREOrCOMMITGate() {
|
||||
// Three layers, three switches. If any two shared one, the day one is turned on would become a
|
||||
// flag day for the other - and commit emission is already on since block 13,889,296 on 2800.
|
||||
assertThat(FalconSealSupport.PROPOSAL_ATTACH_PROPERTY).isNotEqualTo("aere.falcon.attachBlock");
|
||||
assertThat(FalconSealSupport.PROPOSAL_ATTACH_PROPERTY)
|
||||
.isNotEqualTo(FalconSealSupport.PREPARE_ATTACH_PROPERTY);
|
||||
System.setProperty(FalconSealSupport.PROPOSAL_ATTACH_PROPERTY, "16500000");
|
||||
assertThat(FalconSealSupport.proposalAttachBlock()).isEqualTo(16_500_000L);
|
||||
// the commit and PREPARE properties stay untouched by the PROPOSAL one
|
||||
assertThat(System.getProperty("aere.falcon.attachBlock")).isNull();
|
||||
assertThat(System.getProperty(FalconSealSupport.PREPARE_ATTACH_PROPERTY)).isNull();
|
||||
}
|
||||
}
|
||||
@ -82,6 +82,35 @@ public class MessageFactory {
|
||||
return new Proposal(createSignedMessage(payload), roundChanges, prepares);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create proposal carrying the proposer's optional post-quantum seal (AERE PQ, 2026-08-30).
|
||||
*
|
||||
* <p>The seal rides INSIDE the signed payload, so the proposer's ECDSA signature covers it and
|
||||
* nobody can strip or replace it without failing authorship recovery. With the seal absent this
|
||||
* overload produces byte-for-byte what the upstream overload produces.
|
||||
*
|
||||
* @param roundIdentifier the round identifier
|
||||
* @param block the block
|
||||
* @param blockAccessList the block access list
|
||||
* @param roundChanges the round changes
|
||||
* @param prepares the prepares
|
||||
* @param falconSeal the proposer's post-quantum seal, or empty
|
||||
* @return the proposal
|
||||
*/
|
||||
public Proposal createProposal(
|
||||
final ConsensusRoundIdentifier roundIdentifier,
|
||||
final QbftBlock block,
|
||||
final Optional<BlockAccessList> blockAccessList,
|
||||
final List<SignedData<RoundChangePayload>> roundChanges,
|
||||
final List<SignedData<PreparePayload>> prepares,
|
||||
final Optional<FalconSeal> falconSeal) {
|
||||
|
||||
final ProposalPayload payload =
|
||||
new ProposalPayload(roundIdentifier, block, blockEncoder, blockAccessList, falconSeal);
|
||||
|
||||
return new Proposal(createSignedMessage(payload), roundChanges, prepares);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create proposal.
|
||||
*
|
||||
|
||||
@ -0,0 +1,254 @@
|
||||
/*
|
||||
* Copyright ConsenSys AG.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
|
||||
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations under the License.
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
package org.hyperledger.besu.consensus.qbft.core.payload;
|
||||
|
||||
import org.hyperledger.besu.consensus.common.bft.ConsensusRoundIdentifier;
|
||||
import org.hyperledger.besu.consensus.common.bft.FalconSeal;
|
||||
import org.hyperledger.besu.consensus.qbft.core.messagedata.QbftV1;
|
||||
import org.hyperledger.besu.consensus.qbft.core.types.QbftBlock;
|
||||
import org.hyperledger.besu.consensus.qbft.core.types.QbftBlockCodec;
|
||||
import org.hyperledger.besu.ethereum.core.encoding.BlockAccessListDecoder;
|
||||
import org.hyperledger.besu.ethereum.mainnet.block.access.list.BlockAccessList;
|
||||
import org.hyperledger.besu.ethereum.rlp.RLPException;
|
||||
import org.hyperledger.besu.ethereum.rlp.RLPInput;
|
||||
import org.hyperledger.besu.ethereum.rlp.RLPOutput;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
import com.google.common.base.MoreObjects;
|
||||
|
||||
/**
|
||||
* The Proposal payload.
|
||||
*
|
||||
* <p>AERE PQ (2026-08-30), the hot-path step after PREPARE: a PROPOSAL MAY carry an OPTIONAL
|
||||
* Falcon-512 seal from its proposer, appended after the block-access-list element, exactly as
|
||||
* {@code PreparePayload} appends its seal. A proposal without a seal encodes <b>byte for byte as
|
||||
* upstream</b>, which is the property that lets the binary be rolled onto a live fleet without a
|
||||
* flag day.
|
||||
*
|
||||
* <p><b>NOTHING EMITS SUCH A PROPOSAL YET.</b> This file only makes the wire capable of carrying
|
||||
* one and of refusing a malformed one. Emission has its own gate
|
||||
* ({@code aere.pq.proposalPq.attachBlock}); enforcement has its own height
|
||||
* ({@code aere.pq.proposalPq.forkBlock}); both are absent on every node today, and absent means
|
||||
* never.
|
||||
*
|
||||
* <p><b>What the seal signs is NOT this file's business:</b> its own domain
|
||||
* {@code AERE-PQ-PROPOSAL-1} over (chainId, height, ROUND, digest) - see
|
||||
* {@code PqAnchor.proposalMessage}. A proposal is an offer, not a vote: with the commit or PREPARE
|
||||
* domain, a proposal seal given honestly could be pasted onto a forged vote and counted.
|
||||
*
|
||||
* <p><b>Why the trailing element is parsed strictly:</b> the payload is AUTHENTICATED - the author
|
||||
* is recovered from the signature over the encoded payload. A decoder that silently ignored an
|
||||
* unknown trailing element would re-encode without it, so two different byte strings would
|
||||
* authenticate to the same proposer. When a fifth element is present it must be exactly a seal,
|
||||
* and it must be the last element; otherwise the message is refused. Messages of the upstream
|
||||
* shapes (three or four elements) are read exactly as upstream reads them.
|
||||
*/
|
||||
public class ProposalPayload extends QbftPayload {
|
||||
|
||||
private static final int TYPE = QbftV1.PROPOSAL;
|
||||
private final ConsensusRoundIdentifier roundIdentifier;
|
||||
private final QbftBlock proposedBlock;
|
||||
private final QbftBlockCodec blockEncoder;
|
||||
private final Optional<BlockAccessList> blockAccessList;
|
||||
private final Optional<FalconSeal> falconSeal;
|
||||
|
||||
/**
|
||||
* Instantiates a new Proposal payload carrying an optional post-quantum seal of its proposer.
|
||||
*
|
||||
* @param roundIdentifier the round identifier
|
||||
* @param proposedBlock the proposed block
|
||||
* @param blockEncoder the qbft block encoder
|
||||
* @param blockAccessList the block access list
|
||||
* @param falconSeal the proposer's post-quantum seal, or empty
|
||||
*/
|
||||
public ProposalPayload(
|
||||
final ConsensusRoundIdentifier roundIdentifier,
|
||||
final QbftBlock proposedBlock,
|
||||
final QbftBlockCodec blockEncoder,
|
||||
final Optional<BlockAccessList> blockAccessList,
|
||||
final Optional<FalconSeal> falconSeal) {
|
||||
this.roundIdentifier = roundIdentifier;
|
||||
this.proposedBlock = proposedBlock;
|
||||
this.blockEncoder = blockEncoder;
|
||||
this.blockAccessList = blockAccessList;
|
||||
this.falconSeal = falconSeal == null ? Optional.empty() : falconSeal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Instantiates a new Proposal payload, without a post-quantum seal. Encodes byte-for-byte as
|
||||
* upstream Besu.
|
||||
*
|
||||
* @param roundIdentifier the round identifier
|
||||
* @param proposedBlock the proposed block
|
||||
* @param blockEncoder the qbft block encoder
|
||||
* @param blockAccessList the block access list
|
||||
*/
|
||||
public ProposalPayload(
|
||||
final ConsensusRoundIdentifier roundIdentifier,
|
||||
final QbftBlock proposedBlock,
|
||||
final QbftBlockCodec blockEncoder,
|
||||
final Optional<BlockAccessList> blockAccessList) {
|
||||
this(roundIdentifier, proposedBlock, blockEncoder, blockAccessList, Optional.empty());
|
||||
}
|
||||
|
||||
/**
|
||||
* Instantiates a new Proposal payload.
|
||||
*
|
||||
* @param roundIdentifier the round identifier
|
||||
* @param proposedBlock the proposed block
|
||||
* @param blockEncoder the qbft block encoder
|
||||
*/
|
||||
public ProposalPayload(
|
||||
final ConsensusRoundIdentifier roundIdentifier,
|
||||
final QbftBlock proposedBlock,
|
||||
final QbftBlockCodec blockEncoder) {
|
||||
this(roundIdentifier, proposedBlock, blockEncoder, Optional.empty(), Optional.empty());
|
||||
}
|
||||
|
||||
/**
|
||||
* Read from rlp input and return proposal payload.
|
||||
*
|
||||
* @param rlpInput the rlp input
|
||||
* @param blockEncoder the qbft block encoder
|
||||
* @return the proposal payload
|
||||
*/
|
||||
public static ProposalPayload readFrom(
|
||||
final RLPInput rlpInput, final QbftBlockCodec blockEncoder) {
|
||||
rlpInput.enterList();
|
||||
final ConsensusRoundIdentifier roundIdentifier = readConsensusRound(rlpInput);
|
||||
final QbftBlock proposedBlock = blockEncoder.readFrom(rlpInput);
|
||||
final Optional<BlockAccessList> blockAccessList = readBlockAccessList(rlpInput);
|
||||
|
||||
// AERE PQ: the OPTIONAL proposer seal [index, signature]. A proposal without one ends the list
|
||||
// here and decodes to Optional.empty(), so the upstream shapes stay untouched. When a fifth
|
||||
// element exists it must be a seal and it must be last: an unknown trailing element on an
|
||||
// authenticated payload is malleability, not extensibility.
|
||||
Optional<FalconSeal> falconSeal = Optional.empty();
|
||||
if (!rlpInput.isEndOfCurrentList()) {
|
||||
rlpInput.enterList();
|
||||
final int idx = rlpInput.readIntScalar();
|
||||
final org.apache.tuweni.bytes.Bytes sig = rlpInput.readBytes();
|
||||
rlpInput.leaveList();
|
||||
falconSeal = Optional.of(new FalconSeal(idx, sig));
|
||||
if (!rlpInput.isEndOfCurrentList()) {
|
||||
throw new RLPException(
|
||||
"Proposal payload carries elements after the proposer seal; refusing an encoding the "
|
||||
+ "re-encoder would silently drop");
|
||||
}
|
||||
}
|
||||
rlpInput.leaveList();
|
||||
|
||||
return new ProposalPayload(
|
||||
roundIdentifier, proposedBlock, blockEncoder, blockAccessList, falconSeal);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeTo(final RLPOutput rlpOutput) {
|
||||
rlpOutput.startList();
|
||||
writeConsensusRound(rlpOutput);
|
||||
blockEncoder.writeTo(proposedBlock, rlpOutput);
|
||||
blockAccessList.ifPresentOrElse((bal) -> bal.writeTo(rlpOutput), rlpOutput::writeNull);
|
||||
// The seal is written only when present, so a seal-less proposal is identical to upstream.
|
||||
if (falconSeal.isPresent()) {
|
||||
final FalconSeal fs = falconSeal.get();
|
||||
rlpOutput.startList();
|
||||
rlpOutput.writeIntScalar(fs.getValidatorIndex());
|
||||
rlpOutput.writeBytes(fs.getSignature());
|
||||
rlpOutput.endList();
|
||||
}
|
||||
rlpOutput.endList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets proposed block.
|
||||
*
|
||||
* @return the proposed block
|
||||
*/
|
||||
public QbftBlock getProposedBlock() {
|
||||
return proposedBlock;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets block access list.
|
||||
*
|
||||
* @return the block access list
|
||||
*/
|
||||
public Optional<BlockAccessList> getBlockAccessList() {
|
||||
return blockAccessList;
|
||||
}
|
||||
|
||||
/**
|
||||
* The proposer's post-quantum seal, when the message carries one.
|
||||
*
|
||||
* @return the seal, or empty
|
||||
*/
|
||||
public Optional<FalconSeal> getFalconSeal() {
|
||||
return falconSeal;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getMessageType() {
|
||||
return TYPE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ConsensusRoundIdentifier getRoundIdentifier() {
|
||||
return roundIdentifier;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(final Object o) {
|
||||
if (this == o) {
|
||||
return true;
|
||||
}
|
||||
if (o == null || getClass() != o.getClass()) {
|
||||
return false;
|
||||
}
|
||||
ProposalPayload that = (ProposalPayload) o;
|
||||
return Objects.equals(roundIdentifier, that.roundIdentifier)
|
||||
&& Objects.equals(proposedBlock, that.proposedBlock)
|
||||
&& Objects.equals(blockAccessList, that.blockAccessList)
|
||||
&& Objects.equals(falconSeal, that.falconSeal);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(roundIdentifier, proposedBlock, blockAccessList, falconSeal);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return MoreObjects.toStringHelper(this)
|
||||
.add("roundIdentifier", roundIdentifier)
|
||||
.add("proposedBlock", proposedBlock)
|
||||
.add("blockAccessList", blockAccessList)
|
||||
.add("falconSeal", falconSeal.isPresent() ? "present" : "absent")
|
||||
.toString();
|
||||
}
|
||||
|
||||
private static Optional<BlockAccessList> readBlockAccessList(final RLPInput rlpInput) {
|
||||
if (rlpInput.isEndOfCurrentList()) {
|
||||
// Backward compatibility: pre-26.1.0 messages do not include blockAccessList
|
||||
return Optional.empty();
|
||||
}
|
||||
if (!rlpInput.nextIsNull()) {
|
||||
return Optional.of(BlockAccessListDecoder.decode(rlpInput));
|
||||
}
|
||||
rlpInput.skipNext();
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
@ -244,9 +244,18 @@ public class QbftRound {
|
||||
final List<SignedData<PreparePayload>> prepares) {
|
||||
final Proposal proposal;
|
||||
try {
|
||||
// AERE PQ PROPOSAL (2026-08-30): the seal is computed once, here, and rides INSIDE the
|
||||
// signed payload. With the gate closed - every node today - the seal is empty and the OLD
|
||||
// overload is taken call for call, so a node that emits nothing new behaves identically to
|
||||
// one from before this change. Same pattern as PREPARE and commit, deliberately.
|
||||
final Optional<FalconSeal> proposalSeal = proposalSealFor(block);
|
||||
proposal =
|
||||
messageFactory.createProposal(
|
||||
getRoundIdentifier(), block, blockAccessList, roundChanges, prepares);
|
||||
proposalSeal.isPresent()
|
||||
? messageFactory.createProposal(
|
||||
getRoundIdentifier(), block, blockAccessList, roundChanges, prepares,
|
||||
proposalSeal)
|
||||
: messageFactory.createProposal(
|
||||
getRoundIdentifier(), block, blockAccessList, roundChanges, prepares);
|
||||
} catch (final SecurityModuleException e) {
|
||||
LOG.warn("Failed to create a signed Proposal, waiting for next round.", e);
|
||||
return;
|
||||
@ -283,16 +292,16 @@ public class QbftRound {
|
||||
LOG.debug("Sending prepare message. round={}", roundState.getRoundIdentifier());
|
||||
try {
|
||||
// AERE PQ PREPARE (2026-08-28), step 2: the seal is computed EXACTLY ONCE here and is
|
||||
// trece si exemplarului local si celui de pe fir. Semnaturile Falcon sunt randomizate, deci
|
||||
// doua semnari ale aceluiasi mesaj dau doi octeti diferiti; daca fiecare exemplar si-ar semna
|
||||
// handed to both the local copy and the one on the wire. Falcon signatures are randomised,
|
||||
// so two signings of the same message give two different byte strings; if each copy signed
|
||||
// its own, the same validator would produce two valid and DIFFERENT PREPAREs for the same
|
||||
// round. The gate is closed on every node today, so this is empty until a decision.
|
||||
final Optional<FalconSeal> falconSeal = prepareSealFor(block);
|
||||
// WITH THE GATE CLOSED the OLD path is taken, call for call. This is not style: the upstream
|
||||
// tests assert exactly the two-argument call, and more importantly a node that emits nothing new
|
||||
// trebuie sa se comporte identic cu unul de dinaintea acestei schimbari - nu doar sa scrie
|
||||
// aceiasi octeti, ci sa treaca prin aceleasi apeluri. Asa, binarul poate sta pe flota fara
|
||||
// so that nothing changes until a decision is made. Same pattern as commit.
|
||||
// tests assert exactly the two-argument call, and more importantly a node that emits nothing
|
||||
// new must behave identically to one from before this change - not merely write the same
|
||||
// bytes, but go through the same calls. That way the binary can sit on the fleet with
|
||||
// nothing changing until a decision is made. Same pattern as commit.
|
||||
final Prepare localPrepareMessage =
|
||||
falconSeal.isPresent()
|
||||
? messageFactory.createPrepare(getRoundIdentifier(), block.getHash(), falconSeal)
|
||||
@ -311,11 +320,11 @@ public class QbftRound {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sigiliul post-cuantic al acestui nod pentru PREPARE-ul blocului dat, sau gol.
|
||||
* This node's post-quantum seal for the PREPARE of the given block, or empty.
|
||||
*
|
||||
* <p>Mesajul semnat are DOMENIUL LUI si contine RUNDA - vezi PqAnchor.prepareMessage si
|
||||
* proiectarea din 2026-08-28. Cu domeniul commitului, un sigiliu de PREPARE dat cinstit ar putea
|
||||
* fi lipit pe un COMMIT falsificat.
|
||||
* <p>The signed message has ITS OWN DOMAIN and contains the ROUND - see PqAnchor.prepareMessage
|
||||
* and the 2026-08-28 design note. Under the commit domain, a PREPARE seal given honestly could
|
||||
* be pasted onto a forged COMMIT.
|
||||
*/
|
||||
private Optional<FalconSeal> prepareSealFor(final QbftBlock block) {
|
||||
final long blockNumber = block.getHeader().getNumber();
|
||||
@ -328,6 +337,24 @@ public class QbftRound {
|
||||
return FalconSealSupport.instance().signPrepare(blockNumber, message);
|
||||
}
|
||||
|
||||
/**
|
||||
* This node's post-quantum seal for its own PROPOSAL of the given block, or empty.
|
||||
*
|
||||
* <p>Its own domain again - see PqAnchor.proposalMessage. A proposal is an offer, not a vote:
|
||||
* under the PREPARE or commit domain, a proposal seal given honestly could be pasted onto a
|
||||
* forged vote and counted.
|
||||
*/
|
||||
private Optional<FalconSeal> proposalSealFor(final QbftBlock block) {
|
||||
final long blockNumber = block.getHeader().getNumber();
|
||||
final Bytes32 message =
|
||||
PqAnchor.proposalMessage(
|
||||
PqAnchorProducer.config().chainId(),
|
||||
blockNumber,
|
||||
getRoundIdentifier().getRoundNumber(),
|
||||
block.getHash().getBytes());
|
||||
return FalconSealSupport.instance().signProposal(blockNumber, message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle prepare message.
|
||||
*
|
||||
|
||||
@ -0,0 +1,202 @@
|
||||
/*
|
||||
* Copyright contributors to Besu.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
|
||||
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations under the License.
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
package org.hyperledger.besu.consensus.qbft.core.validation;
|
||||
|
||||
import org.hyperledger.besu.consensus.common.bft.FalconSeal;
|
||||
import org.hyperledger.besu.consensus.common.bft.PqAnchor;
|
||||
import org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer;
|
||||
import org.hyperledger.besu.consensus.common.bft.PqSignerRegistry;
|
||||
import org.hyperledger.besu.datatypes.Address;
|
||||
import org.hyperledger.besu.datatypes.Hash;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import org.apache.tuweni.bytes.Bytes32;
|
||||
|
||||
/**
|
||||
* POST-QUANTUM ENFORCEMENT ON THE PROPOSAL. The hot-path step after PREPARE (AERE PQ, 2026-08-30).
|
||||
*
|
||||
* <p>From the armed height onwards, a PROPOSAL is not accepted without a valid post-quantum seal
|
||||
* from its OWN proposer. The structure copies {@link PqProposalEnforcement} line for line on
|
||||
* purpose: a second rendering of the same idea, written differently, diverges eventually.
|
||||
*
|
||||
* <p><b>WHAT THIS LAYER BUYS, said precisely.</b> A proposal is an OFFER, not a vote: the design
|
||||
* note of 2026-08-28 measures that "prepared" needs a full quorum of PREPAREs, proposer included,
|
||||
* so an adversary who breaks ECDSA and forges proposals still cannot finalize anything while the
|
||||
* PREPARE layer is armed. What forged proposals CAN do is start rounds and waste them - steering
|
||||
* which honest proposals get considered and degrading liveness. This layer closes that: a proposal
|
||||
* whose proposer cannot produce a Falcon seal does not even open a round on an armed node.
|
||||
*
|
||||
* <p><b>WHAT THE SEAL SIGNS.</b> Its own domain, {@code AERE-PQ-PROPOSAL-1}, over (chainId,
|
||||
* height, ROUND, digest). Under the PREPARE or commit domain, a proposal seal given honestly could
|
||||
* be pasted onto a forged vote and counted; one changed string in the preimage makes the
|
||||
* signatures non-transferable in both directions.
|
||||
*
|
||||
* <p><b>DISARMED BY DEFAULT.</b> Without the property, {@link #fromSystemConfig()} returns null and
|
||||
* the validator behaves exactly as upstream. A value that is PRESENT but unreadable REFUSES loudly:
|
||||
* a node that boots disarmed because of a mistyped character looks exactly like a correctly
|
||||
* configured one, right up to the day it matters.
|
||||
*/
|
||||
public final class PqProposalEnforcement {
|
||||
|
||||
/** The height nothing ever reaches: disarmed. */
|
||||
public static final long DISARMED = Long.MAX_VALUE;
|
||||
|
||||
/** The property that arms PROPOSAL enforcement. */
|
||||
public static final String PROPERTY_FORK_BLOCK = "aere.pq.proposalPq.forkBlock";
|
||||
|
||||
/** The equivalent environment variable. */
|
||||
public static final String ENV_FORK_BLOCK = "AERE_PQ_PROPOSALPQ_FORKBLOCK";
|
||||
|
||||
private final long armedFromBlock;
|
||||
private final PqSignerRegistry registry;
|
||||
private final long chainId;
|
||||
|
||||
/**
|
||||
* @param armedFromBlock first height (inclusive) at which enforcement applies; {@link #DISARMED}
|
||||
* for today's behaviour
|
||||
* @param registry the signer registry, injected, never a singleton
|
||||
* @param chainId the chain that goes into the signed message
|
||||
*/
|
||||
public PqProposalEnforcement(
|
||||
final long armedFromBlock, final PqSignerRegistry registry, final long chainId) {
|
||||
this.armedFromBlock = armedFromBlock;
|
||||
this.registry = registry;
|
||||
this.chainId = chainId;
|
||||
}
|
||||
|
||||
/**
|
||||
* The same enforcement, with the chain id taken from the anchor configuration.
|
||||
*
|
||||
* <p>THE CHAIN ID IS AN ARGUMENT, NOT A GLOBAL, and its own test caught that: the first version
|
||||
* read it from {@code PqAnchorProducer.config()} in the middle of a consensus decision, so the
|
||||
* test signed over 2800 while the enforcement verified over whatever the process configuration
|
||||
* happened to be. A consensus decision that depends on global state cannot be tested honestly,
|
||||
* and cannot be read either. The factories below fetch the value once, at construction, where it
|
||||
* is visible.
|
||||
*
|
||||
* @param armedFromBlock first height at which enforcement applies
|
||||
* @param registry the signer registry
|
||||
*/
|
||||
public PqProposalEnforcement(final long armedFromBlock, final PqSignerRegistry registry) {
|
||||
this(armedFromBlock, registry, PqAnchorProducer.config().chainId());
|
||||
}
|
||||
|
||||
/**
|
||||
* The configured enforcement, read FRESH on every call.
|
||||
*
|
||||
* @return the armed enforcement, or null when the property is set nowhere
|
||||
* @throws IllegalStateException AERE-PQC-PROPOSAL-ENF-01 when the value is present but is not a
|
||||
* non-negative decimal height
|
||||
*/
|
||||
public static PqProposalEnforcement fromSystemConfig() {
|
||||
String raw = System.getProperty(PROPERTY_FORK_BLOCK);
|
||||
if (raw == null) {
|
||||
raw = System.getenv(ENV_FORK_BLOCK);
|
||||
}
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
final long armedFrom;
|
||||
try {
|
||||
armedFrom = Long.parseLong(raw.trim());
|
||||
if (armedFrom < 0) {
|
||||
throw new NumberFormatException("negative");
|
||||
}
|
||||
} catch (final NumberFormatException e) {
|
||||
throw new IllegalStateException(
|
||||
"AERE-PQC-PROPOSAL-ENF-01: "
|
||||
+ PROPERTY_FORK_BLOCK
|
||||
+ " is set but not a non-negative block height: '"
|
||||
+ raw
|
||||
+ "'. A mistyped value must refuse, never silently disarm.");
|
||||
}
|
||||
return new PqProposalEnforcement(armedFrom, PqSignerRegistry.falconSealSupport());
|
||||
}
|
||||
|
||||
/** Whether enforcement is active at this height. */
|
||||
public boolean armedAt(final long height) {
|
||||
return height >= armedFromBlock;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decides whether a PROPOSAL may be accepted.
|
||||
*
|
||||
* @param height the height the PROPOSAL targets (the round's sequence number)
|
||||
* @param round the PROPOSAL's round; it is part of the signed message
|
||||
* @param author the RECOVERED author of the signed message (from its ECDSA signature)
|
||||
* @param digest the digest of the proposed block
|
||||
* @param seal the post-quantum seal carried by the payload, if any
|
||||
* @return empty when the PROPOSAL is acceptable; otherwise the refusal, with names and numbers
|
||||
*/
|
||||
public Optional<String> refusal(
|
||||
final long height,
|
||||
final int round,
|
||||
final Address author,
|
||||
final Hash digest,
|
||||
final Optional<FalconSeal> seal) {
|
||||
if (!armedAt(height)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
if (seal == null || seal.isEmpty()) {
|
||||
return Optional.of(
|
||||
"AERE FULL-PQ: proposal at height " + height + " round " + round
|
||||
+ " carries NO post-quantum seal and enforcement is armed from " + armedFromBlock
|
||||
+ " - the proposal is refused");
|
||||
}
|
||||
final FalconSeal fs = seal.get();
|
||||
final Address bound;
|
||||
try {
|
||||
bound = registry.addressForIndexAtOwnHead(height, fs.getValidatorIndex());
|
||||
} catch (final RuntimeException e) {
|
||||
return Optional.of(
|
||||
"AERE FULL-PQ: registry refused index " + fs.getValidatorIndex() + " at height "
|
||||
+ height + ": " + e.getMessage());
|
||||
}
|
||||
if (bound == null || !bound.equals(author)) {
|
||||
return Optional.of(
|
||||
"AERE FULL-PQ: proposal seal index " + fs.getValidatorIndex() + " is bound to " + bound
|
||||
+ " but the proposal was authored by " + author
|
||||
+ " - a seal cannot vouch for someone else's proposal");
|
||||
}
|
||||
|
||||
final Bytes32 message;
|
||||
try {
|
||||
message = PqAnchor.proposalMessage(chainId, height, round, digest.getBytes());
|
||||
} catch (final RuntimeException e) {
|
||||
// A message we cannot build means we cannot judge, and "cannot judge" must never be a pass:
|
||||
// that would be exactly the silent disarming this file exists to refuse.
|
||||
return Optional.of(
|
||||
"AERE FULL-PQ: could not build the proposal message at height " + height + " round "
|
||||
+ round + ": " + e.getMessage());
|
||||
}
|
||||
|
||||
final boolean valid;
|
||||
try {
|
||||
valid = registry.verifyAtOwnHead(height, fs.getValidatorIndex(), message, fs.getSignature());
|
||||
} catch (final RuntimeException e) {
|
||||
return Optional.of(
|
||||
"AERE FULL-PQ: verification threw for index " + fs.getValidatorIndex() + " at height "
|
||||
+ height + ": " + e.getMessage());
|
||||
}
|
||||
if (!valid) {
|
||||
return Optional.of(
|
||||
"AERE FULL-PQ: post-quantum seal of index " + fs.getValidatorIndex()
|
||||
+ " does NOT verify over the proposal message at height " + height + " round " + round
|
||||
+ " - the proposal is refused");
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,182 @@
|
||||
/*
|
||||
* Copyright contributors to Hyperledger Besu.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
|
||||
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations under the License.
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
package org.hyperledger.besu.consensus.qbft.core.validation;
|
||||
|
||||
import static com.google.common.base.Preconditions.checkState;
|
||||
|
||||
import org.hyperledger.besu.consensus.common.bft.ConsensusRoundIdentifier;
|
||||
import org.hyperledger.besu.consensus.common.bft.payload.SignedData;
|
||||
import org.hyperledger.besu.consensus.qbft.core.payload.ProposalPayload;
|
||||
import org.hyperledger.besu.consensus.qbft.core.types.QbftBlock;
|
||||
import org.hyperledger.besu.consensus.qbft.core.types.QbftBlockValidator;
|
||||
import org.hyperledger.besu.datatypes.Address;
|
||||
import org.hyperledger.besu.ethereum.mainnet.block.access.list.BlockAccessList;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* The Proposal payload validator.
|
||||
*
|
||||
* <p>AERE (2026-08-30): an OPTIONAL post-quantum enforcement hook, gated on height (see {@link
|
||||
* PqProposalEnforcement}). When no enforcement is supplied, behaviour is byte for byte the upstream
|
||||
* one - and that is the configuration of every node today. Same pattern as the PREPARE and commit
|
||||
* validators, deliberately: a second rendering of the same idea, written differently, diverges
|
||||
* eventually.
|
||||
*
|
||||
* <p>The 3-argument constructor - the one production code calls - wires the enforcement itself
|
||||
* from the system configuration, exactly as {@link PrepareValidator} does. That self-wiring is the
|
||||
* security property: an enforcement that had to be passed in explicitly could be dropped by any
|
||||
* refactor without a single test failing.
|
||||
*/
|
||||
public class ProposalPayloadValidator {
|
||||
|
||||
private static final String ERROR_PREFIX = "Invalid Proposal Payload";
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(ProposalPayloadValidator.class);
|
||||
private final Address expectedProposer;
|
||||
private final ConsensusRoundIdentifier targetRound;
|
||||
private final QbftBlockValidator blockValidator;
|
||||
private final PqProposalEnforcement pqEnforcement;
|
||||
|
||||
/**
|
||||
* Instantiates a new Proposal payload validator, with the post-quantum enforcement wired from
|
||||
* the system configuration. This is the constructor production code calls.
|
||||
*
|
||||
* @param expectedProposer the expected proposer
|
||||
* @param targetRound the target round
|
||||
* @param blockValidator the block validator
|
||||
*/
|
||||
@VisibleForTesting
|
||||
public ProposalPayloadValidator(
|
||||
final Address expectedProposer,
|
||||
final ConsensusRoundIdentifier targetRound,
|
||||
final QbftBlockValidator blockValidator) {
|
||||
this(expectedProposer, targetRound, blockValidator, PqProposalEnforcement.fromSystemConfig());
|
||||
}
|
||||
|
||||
/**
|
||||
* Instantiates a new Proposal payload validator with an explicit post-quantum enforcement. Test
|
||||
* seam; production goes through the 3-argument constructor above.
|
||||
*
|
||||
* @param expectedProposer the expected proposer
|
||||
* @param targetRound the target round
|
||||
* @param blockValidator the block validator
|
||||
* @param pqEnforcement the post-quantum enforcement, or null for upstream behaviour
|
||||
*/
|
||||
@VisibleForTesting
|
||||
public ProposalPayloadValidator(
|
||||
final Address expectedProposer,
|
||||
final ConsensusRoundIdentifier targetRound,
|
||||
final QbftBlockValidator blockValidator,
|
||||
final PqProposalEnforcement pqEnforcement) {
|
||||
this.expectedProposer = expectedProposer;
|
||||
this.targetRound = targetRound;
|
||||
this.blockValidator = blockValidator;
|
||||
this.pqEnforcement = pqEnforcement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate.
|
||||
*
|
||||
* @param signedPayload the signed Proposal payload
|
||||
* @return the boolean
|
||||
*/
|
||||
public boolean validate(final SignedData<ProposalPayload> signedPayload) {
|
||||
return validate(signedPayload, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate without block validation.
|
||||
*
|
||||
* @param signedPayload the signed Proposal payload
|
||||
* @return the boolean
|
||||
*/
|
||||
public boolean validateWithoutBlockValidation(final SignedData<ProposalPayload> signedPayload) {
|
||||
return validate(signedPayload, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate with optional block validation.
|
||||
*
|
||||
* @param signedPayload the signed Proposal payload
|
||||
* @param validateBlock whether to validate the block
|
||||
* @return the boolean
|
||||
*/
|
||||
private boolean validate(
|
||||
final SignedData<ProposalPayload> signedPayload, final boolean validateBlock) {
|
||||
|
||||
if (!signedPayload.getAuthor().equals(expectedProposer)) {
|
||||
LOG.info("{}: proposal created by non-proposer", ERROR_PREFIX);
|
||||
return false;
|
||||
}
|
||||
|
||||
final ProposalPayload payload = signedPayload.getPayload();
|
||||
|
||||
if (!payload.getRoundIdentifier().equals(targetRound)) {
|
||||
LOG.info("{}: proposal is not for expected round", ERROR_PREFIX);
|
||||
return false;
|
||||
}
|
||||
|
||||
final QbftBlock block = payload.getProposedBlock();
|
||||
if (validateBlock && !validateBlock(block, payload.getBlockAccessList())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (block.getHeader().getNumber() != payload.getRoundIdentifier().getSequenceNumber()) {
|
||||
LOG.info("{}: block number does not match sequence number", ERROR_PREFIX);
|
||||
return false;
|
||||
}
|
||||
|
||||
// AERE full-PQ: from the armed height, a proposal is not accepted without a valid post-quantum
|
||||
// seal from its OWN proposer. Below the height, and on every node without the property, this
|
||||
// is a null check and nothing more - the upstream path, call for call.
|
||||
if (pqEnforcement != null) {
|
||||
final Optional<String> refusal =
|
||||
pqEnforcement.refusal(
|
||||
payload.getRoundIdentifier().getSequenceNumber(),
|
||||
payload.getRoundIdentifier().getRoundNumber(),
|
||||
signedPayload.getAuthor(),
|
||||
block.getHash(),
|
||||
payload.getFalconSeal());
|
||||
if (refusal.isPresent()) {
|
||||
LOG.info("{}: {}", ERROR_PREFIX, refusal.get());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean validateBlock(
|
||||
final QbftBlock block, final Optional<BlockAccessList> blockAccessList) {
|
||||
checkState(blockValidator != null, "block validation not possible, no block validator.");
|
||||
|
||||
final var validationResult = blockValidator.validateBlock(block, blockAccessList);
|
||||
|
||||
if (!validationResult.success()) {
|
||||
LOG.info(
|
||||
"{}: block did not pass validation. Reason {}",
|
||||
ERROR_PREFIX,
|
||||
validationResult.errorMessage());
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,202 @@
|
||||
/*
|
||||
* Copyright contributors to Besu.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
|
||||
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations under the License.
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
package org.hyperledger.besu.consensus.qbft.core.payload;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
|
||||
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.qbft.core.types.QbftBlock;
|
||||
import org.hyperledger.besu.consensus.qbft.core.types.QbftBlockCodec;
|
||||
import org.hyperledger.besu.ethereum.rlp.BytesValueRLPInput;
|
||||
import org.hyperledger.besu.ethereum.rlp.BytesValueRLPOutput;
|
||||
import org.hyperledger.besu.ethereum.rlp.RLPException;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import org.apache.tuweni.bytes.Bytes;
|
||||
import org.apache.tuweni.bytes.Bytes32;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
/**
|
||||
* The wire can carry a post-quantum seal on a PROPOSAL, and NOTHING emits one yet (AERE PQ,
|
||||
* 2026-08-30, the hot-path step after PREPARE).
|
||||
*
|
||||
* <p>The test that matters most is the first one: a proposal without a seal encodes EXACTLY as
|
||||
* upstream does. The block codec is mocked and contributes zero bytes on both sides of the round
|
||||
* trip, which is what lets the surrounding list structure be compared against a hand-built
|
||||
* expected form instead of against a run of the code under test.
|
||||
*
|
||||
* <p>The strictness tests close the malleability door this file's javadoc names: an authenticated
|
||||
* payload whose decoder silently ignored trailing elements would let two different byte strings
|
||||
* authenticate to the same proposer.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
public class ProposalPayloadPqTest {
|
||||
|
||||
private static final ConsensusRoundIdentifier ROUND_ID = new ConsensusRoundIdentifier(7, 3);
|
||||
|
||||
@Mock private QbftBlockCodec blockEncoder;
|
||||
@Mock private QbftBlock block;
|
||||
|
||||
@BeforeEach
|
||||
void wireCodec() {
|
||||
// The mock codec writes nothing and reads nothing: the block contributes zero elements on both
|
||||
// sides, so the tests compare the STRUCTURE around it, which is what this file changed.
|
||||
lenient().when(blockEncoder.readFrom(any())).thenReturn(block);
|
||||
}
|
||||
|
||||
/** The old form with the mocked block: RLP[ sequence, round, null-BAL ]. Built by hand. */
|
||||
private static Bytes oldForm() {
|
||||
final BytesValueRLPOutput out = new BytesValueRLPOutput();
|
||||
out.startList();
|
||||
out.writeLongScalar(ROUND_ID.getSequenceNumber());
|
||||
out.writeIntScalar(ROUND_ID.getRoundNumber());
|
||||
out.writeNull();
|
||||
out.endList();
|
||||
return out.encoded();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aProposalWithoutASealEncodesEXACTLYAsBefore() {
|
||||
final ProposalPayload p = new ProposalPayload(ROUND_ID, block, blockEncoder, Optional.empty());
|
||||
assertThat(p.encoded()).isEqualTo(oldForm());
|
||||
// and the signature hash, which is precisely what binds the proposer to the message
|
||||
assertThat(p.hashForSignature())
|
||||
.isEqualTo(
|
||||
new ProposalPayload(ROUND_ID, block, blockEncoder, Optional.empty(), Optional.empty())
|
||||
.hashForSignature());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aProposalWithoutASealReadsBackIdentical() {
|
||||
final Bytes encoded = new ProposalPayload(ROUND_ID, block, blockEncoder, Optional.empty()).encoded();
|
||||
final ProposalPayload read =
|
||||
ProposalPayload.readFrom(new BytesValueRLPInput(encoded, false), blockEncoder);
|
||||
assertThat(read.getFalconSeal()).isEmpty();
|
||||
assertThat(read.getRoundIdentifier()).isEqualTo(ROUND_ID);
|
||||
assertThat(read.encoded()).isEqualTo(encoded);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aProposalWithASealReadsBackIdentical() {
|
||||
final FalconSeal seal = new FalconSeal(4, Bytes.fromHexString("0xdeadbeef"));
|
||||
final ProposalPayload p =
|
||||
new ProposalPayload(ROUND_ID, block, blockEncoder, Optional.empty(), Optional.of(seal));
|
||||
final Bytes encoded = p.encoded();
|
||||
|
||||
// it is longer than the old form, and CONTAINS it as a prefix of the content
|
||||
assertThat(encoded.size()).isGreaterThan(oldForm().size());
|
||||
|
||||
final ProposalPayload read =
|
||||
ProposalPayload.readFrom(new BytesValueRLPInput(encoded, false), blockEncoder);
|
||||
assertThat(read.getFalconSeal()).isPresent();
|
||||
assertThat(read.getFalconSeal().get().getValidatorIndex()).isEqualTo(4);
|
||||
assertThat(read.getFalconSeal().get().getSignature())
|
||||
.isEqualTo(Bytes.fromHexString("0xdeadbeef"));
|
||||
assertThat(read.encoded()).isEqualTo(encoded);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aSealChangesTheSignatureHash() {
|
||||
// If it did not change it, the proposer's ECDSA signature would not cover the seal, and anyone
|
||||
// could paste a foreign index onto an otherwise valid proposal.
|
||||
final ProposalPayload without = new ProposalPayload(ROUND_ID, block, blockEncoder, Optional.empty());
|
||||
final ProposalPayload with =
|
||||
new ProposalPayload(
|
||||
ROUND_ID,
|
||||
block,
|
||||
blockEncoder,
|
||||
Optional.empty(),
|
||||
Optional.of(new FalconSeal(4, Bytes.fromHexString("0xdeadbeef"))));
|
||||
assertThat(with.hashForSignature()).isNotEqualTo(without.hashForSignature());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void anElementAfterTheSealIsRefused() {
|
||||
// A decoder that ignored it would re-encode without it: two different byte strings would
|
||||
// authenticate to the same proposer. It is refused.
|
||||
final BytesValueRLPOutput out = new BytesValueRLPOutput();
|
||||
out.startList();
|
||||
out.writeLongScalar(ROUND_ID.getSequenceNumber());
|
||||
out.writeIntScalar(ROUND_ID.getRoundNumber());
|
||||
out.writeNull();
|
||||
out.startList();
|
||||
out.writeIntScalar(4);
|
||||
out.writeBytes(Bytes.fromHexString("0xdeadbeef"));
|
||||
out.endList();
|
||||
out.writeIntScalar(1); // the trailing element nothing accounts for
|
||||
out.endList();
|
||||
|
||||
assertThatThrownBy(
|
||||
() -> ProposalPayload.readFrom(new BytesValueRLPInput(out.encoded(), false), blockEncoder))
|
||||
.isInstanceOf(RLPException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aFifthElementThatIsNotASealIsRefused() {
|
||||
final BytesValueRLPOutput out = new BytesValueRLPOutput();
|
||||
out.startList();
|
||||
out.writeLongScalar(ROUND_ID.getSequenceNumber());
|
||||
out.writeIntScalar(ROUND_ID.getRoundNumber());
|
||||
out.writeNull();
|
||||
out.writeBytes(Bytes.fromHexString("0x01")); // not a [index, signature] list
|
||||
out.endList();
|
||||
|
||||
assertThatThrownBy(
|
||||
() -> ProposalPayload.readFrom(new BytesValueRLPInput(out.encoded(), false), blockEncoder))
|
||||
.isInstanceOf(RLPException.class);
|
||||
}
|
||||
|
||||
// ---- domain separation: the security part of the design --------------------------------------
|
||||
|
||||
private static final Bytes32 DIGEST =
|
||||
Bytes32.fromHexString("0x000000000000000000000000000000000000000000000000000000000000002a");
|
||||
|
||||
@Test
|
||||
public void theProposalMessageIsNeitherThePrepareNorTheCommitMessage() {
|
||||
// If any two were the same, a seal given HONESTLY in one role could be replayed in the other:
|
||||
// an offer counted as a vote, or a vote replayed as an offer.
|
||||
final Bytes32 proposal = PqAnchor.proposalMessage(2800L, 100L, 3, DIGEST);
|
||||
assertThat(proposal).isNotEqualTo(PqAnchor.prepareMessage(2800L, 100L, 3, DIGEST));
|
||||
assertThat(proposal).isNotEqualTo(PqAnchor.commitMessage(2800L, 100L, DIGEST));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void theProposalMessageDependsOnROUNDChainAndHeight() {
|
||||
final Bytes32 base = PqAnchor.proposalMessage(2800L, 100L, 3, DIGEST);
|
||||
assertThat(PqAnchor.proposalMessage(2800L, 100L, 4, DIGEST)).isNotEqualTo(base);
|
||||
assertThat(PqAnchor.proposalMessage(2801L, 100L, 3, DIGEST)).isNotEqualTo(base);
|
||||
assertThat(PqAnchor.proposalMessage(2800L, 101L, 3, DIGEST)).isNotEqualTo(base);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void theProposalMessageRefusesImpossibleInputs() {
|
||||
assertThatThrownBy(() -> PqAnchor.proposalMessage(2800L, -1L, 3, DIGEST))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> PqAnchor.proposalMessage(2800L, 100L, -1, DIGEST))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> PqAnchor.proposalMessage(2800L, 100L, 3, Bytes.fromHexString("0x00")))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,297 @@
|
||||
/*
|
||||
* Copyright contributors to Besu.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
|
||||
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations under the License.
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
package org.hyperledger.besu.consensus.qbft.core.validation;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import org.hyperledger.besu.consensus.common.bft.FalconSeal;
|
||||
import org.hyperledger.besu.consensus.common.bft.PqAnchor;
|
||||
import org.hyperledger.besu.consensus.common.bft.PqSignerRegistry;
|
||||
import org.hyperledger.besu.consensus.common.bft.SealScheme;
|
||||
import org.hyperledger.besu.consensus.common.bft.SealSchemes;
|
||||
import org.hyperledger.besu.crypto.SecureRandomProvider;
|
||||
import org.hyperledger.besu.datatypes.Address;
|
||||
import org.hyperledger.besu.datatypes.Hash;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.apache.tuweni.bytes.Bytes;
|
||||
import org.apache.tuweni.bytes.Bytes32;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* PROPOSAL ENFORCEMENT. The structure follows PqPrepareEnforcementTest deliberately - the same
|
||||
* eight ways of being wrong, each with the test that closes it.
|
||||
*
|
||||
* <ol>
|
||||
* <li>below the arming height NOTHING changes - the condition for the binary to sit on the fleet;
|
||||
* <li>above it, a PROPOSAL without a seal is refused;
|
||||
* <li>a seal from ANOTHER validator does not vouch for this proposer;
|
||||
* <li>a seal over a DIFFERENT MESSAGE does not pass - tested against BOTH the PREPARE and the
|
||||
* COMMIT messages, because a proposal seal that passed under either domain would let an
|
||||
* honest proposal seal be replayed as a vote, or an honest vote seal be replayed as a
|
||||
* proposal;
|
||||
* <li>a seal from a DIFFERENT ROUND does not pass;
|
||||
* <li>an index the registry binds to nobody does not pass;
|
||||
* <li>a mistyped configuration REFUSES, it does not disarm;
|
||||
* <li>the enforcement gate and the emission gate are two different switches.
|
||||
* </ol>
|
||||
*
|
||||
* <p>The keys are REAL Falcon keys, generated in-process, and verification goes through the real
|
||||
* scheme. A test with fake signatures would prove that we can compare strings, not that the
|
||||
* enforcement enforces.
|
||||
*/
|
||||
class PqProposalEnforcementTest {
|
||||
|
||||
private static final long ARMED_FROM = 1_000_000L;
|
||||
private static final int ROUND = 3;
|
||||
private static final Address PROPOSER_0 = Address.fromHexString("0x" + "aa".repeat(20));
|
||||
private static final Address VALIDATOR_1 = Address.fromHexString("0x" + "bb".repeat(20));
|
||||
private static final Hash DIGEST = Hash.hash(Bytes.of(8, 8, 8));
|
||||
private static final long CHAIN_ID = 2800L;
|
||||
|
||||
private final SecureRandom random = SecureRandomProvider.createSecureRandom();
|
||||
|
||||
@AfterEach
|
||||
void clearProperty() {
|
||||
System.clearProperty(PqProposalEnforcement.PROPERTY_FORK_BLOCK);
|
||||
}
|
||||
|
||||
/** Test registry: programmed index-to-address bindings + verification through the REAL scheme. */
|
||||
private static final class TestRegistry implements PqSignerRegistry {
|
||||
final Map<Integer, Address> bindings;
|
||||
final Map<Integer, byte[]> keys;
|
||||
|
||||
TestRegistry(final Map<Integer, Address> bindings, final Map<Integer, byte[]> keys) {
|
||||
this.bindings = bindings;
|
||||
this.keys = keys;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Address addressForIndexAtHistoric(final long blockNumber, final int validatorIndex) {
|
||||
return bindings.get(validatorIndex);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Address addressForIndexAtOwnHead(final long blockNumber, final int validatorIndex) {
|
||||
return bindings.get(validatorIndex);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean verifyAtHistoric(
|
||||
final long blockNumber, final int validatorIndex, final Bytes message, final Bytes signature) {
|
||||
return verifyAtOwnHead(blockNumber, validatorIndex, message, signature);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean verifyAtOwnHead(
|
||||
final long blockNumber, final int validatorIndex, final Bytes message, final Bytes signature) {
|
||||
final byte[] pk = keys.get(validatorIndex);
|
||||
if (pk == null) {
|
||||
return false;
|
||||
}
|
||||
return SealSchemes.FALCON_512.verifyRaw(pk, message.toArray(), signature.toArray());
|
||||
}
|
||||
}
|
||||
|
||||
private record World(
|
||||
PqProposalEnforcement enforcement,
|
||||
FalconSeal valid0,
|
||||
SealScheme.GeneratedPair k0,
|
||||
SealScheme.GeneratedPair k1) {}
|
||||
|
||||
private Bytes32 proposalMessage(final long h, final int round) {
|
||||
return PqAnchor.proposalMessage(CHAIN_ID, h, round, DIGEST.getBytes());
|
||||
}
|
||||
|
||||
/** Two validators with real Falcon keys; validator 0's seal over the PROPOSAL message. */
|
||||
private World world() {
|
||||
final SealScheme.GeneratedPair k0 = SealSchemes.FALCON_512.generate(random);
|
||||
final SealScheme.GeneratedPair k1 = SealSchemes.FALCON_512.generate(random);
|
||||
final byte[] sig0 =
|
||||
SealSchemes.FALCON_512
|
||||
.sign(k0.privateKey(), proposalMessage(ARMED_FROM, ROUND).toArray())
|
||||
.orElseThrow();
|
||||
final TestRegistry reg =
|
||||
new TestRegistry(
|
||||
Map.of(0, PROPOSER_0, 1, VALIDATOR_1),
|
||||
Map.of(0, k0.publicRegistryForm(), 1, k1.publicRegistryForm()));
|
||||
return new World(
|
||||
new PqProposalEnforcement(ARMED_FROM, reg, CHAIN_ID),
|
||||
new FalconSeal(0, Bytes.wrap(sig0)),
|
||||
k0,
|
||||
k1);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// 1. BELOW the arming height NOTHING changes. The condition for the binary to sit on the fleet.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
@Test
|
||||
void belowTheArmingHeightAnUnsealedProposalIsAccepted() {
|
||||
final World w = world();
|
||||
assertThat(w.enforcement().armedAt(ARMED_FROM - 1)).isFalse();
|
||||
assertThat(w.enforcement().refusal(ARMED_FROM - 1, ROUND, PROPOSER_0, DIGEST, Optional.empty()))
|
||||
.isEmpty();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// 2. ABOVE it, a PROPOSAL without a seal is refused.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
@Test
|
||||
void aboveTheHeightAnUnsealedProposalIsRefused() {
|
||||
final World w = world();
|
||||
final Optional<String> refusal =
|
||||
w.enforcement().refusal(ARMED_FROM, ROUND, PROPOSER_0, DIGEST, Optional.empty());
|
||||
assertThat(refusal).isPresent();
|
||||
assertThat(refusal.get()).contains("carries NO post-quantum seal");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// 3. A GOOD seal from the proposer passes.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
@Test
|
||||
void aGoodSealFromTheProposerPasses() {
|
||||
final World w = world();
|
||||
assertThat(w.enforcement().refusal(ARMED_FROM, ROUND, PROPOSER_0, DIGEST, Optional.of(w.valid0())))
|
||||
.isEmpty();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// 4. The same seal, a different author: it does not vouch for somebody else.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
@Test
|
||||
void aSealDoesNotVouchForAnotherProposer() {
|
||||
final World w = world();
|
||||
final Optional<String> refusal =
|
||||
w.enforcement().refusal(ARMED_FROM, ROUND, VALIDATOR_1, DIGEST, Optional.of(w.valid0()));
|
||||
assertThat(refusal).isPresent();
|
||||
assertThat(refusal.get()).contains("cannot vouch for someone else's proposal");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// 5. DOMAIN SEPARATION, in both directions that matter here: a seal given HONESTLY over the
|
||||
// PREPARE message, and one over the COMMIT message, must not pass as a PROPOSAL seal. Either
|
||||
// passing would make honest seals transferable between an offer and a vote.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
@Test
|
||||
void aSealOverThePREPAREMessageDoesNotPassAsPROPOSAL() {
|
||||
final World w = world();
|
||||
final Bytes32 prepareMsg = PqAnchor.prepareMessage(CHAIN_ID, ARMED_FROM, ROUND, DIGEST.getBytes());
|
||||
final byte[] sig =
|
||||
SealSchemes.FALCON_512.sign(w.k0().privateKey(), prepareMsg.toArray()).orElseThrow();
|
||||
|
||||
final Optional<String> refusal =
|
||||
w.enforcement()
|
||||
.refusal(
|
||||
ARMED_FROM, ROUND, PROPOSER_0, DIGEST, Optional.of(new FalconSeal(0, Bytes.wrap(sig))));
|
||||
assertThat(refusal).isPresent();
|
||||
assertThat(refusal.get()).contains("does NOT verify over the proposal message");
|
||||
}
|
||||
|
||||
@Test
|
||||
void aSealOverTheCOMMITMessageDoesNotPassAsPROPOSAL() {
|
||||
final World w = world();
|
||||
final Bytes32 commitMsg = PqAnchor.commitMessage(CHAIN_ID, ARMED_FROM, DIGEST.getBytes());
|
||||
final byte[] sig =
|
||||
SealSchemes.FALCON_512.sign(w.k0().privateKey(), commitMsg.toArray()).orElseThrow();
|
||||
|
||||
final Optional<String> refusal =
|
||||
w.enforcement()
|
||||
.refusal(
|
||||
ARMED_FROM, ROUND, PROPOSER_0, DIGEST, Optional.of(new FalconSeal(0, Bytes.wrap(sig))));
|
||||
assertThat(refusal).isPresent();
|
||||
assertThat(refusal.get()).contains("does NOT verify over the proposal message");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// 6. The ROUND is in the preimage: a seal from a failed round does not open another one.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
@Test
|
||||
void aSealFromAnotherROUNDDoesNotPass() {
|
||||
final World w = world();
|
||||
final byte[] sigOtherRound =
|
||||
SealSchemes.FALCON_512
|
||||
.sign(w.k0().privateKey(), proposalMessage(ARMED_FROM, ROUND + 1).toArray())
|
||||
.orElseThrow();
|
||||
final Optional<String> refusal =
|
||||
w.enforcement()
|
||||
.refusal(
|
||||
ARMED_FROM,
|
||||
ROUND,
|
||||
PROPOSER_0,
|
||||
DIGEST,
|
||||
Optional.of(new FalconSeal(0, Bytes.wrap(sigOtherRound))));
|
||||
assertThat(refusal).isPresent();
|
||||
assertThat(refusal.get()).contains("does NOT verify over the proposal message");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// 7. An index the registry binds to nobody.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
@Test
|
||||
void anUNBOUNDIndexDoesNotPass() {
|
||||
final World w = world();
|
||||
final Optional<String> refusal =
|
||||
w.enforcement()
|
||||
.refusal(
|
||||
ARMED_FROM,
|
||||
ROUND,
|
||||
PROPOSER_0,
|
||||
DIGEST,
|
||||
Optional.of(new FalconSeal(99, w.valid0().getSignature())));
|
||||
assertThat(refusal).isPresent();
|
||||
assertThat(refusal.get()).contains("is bound to null");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// 8. CONFIGURATION: absent = disarmed; a mistyped value = REFUSAL, never a silent disarming.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
@Test
|
||||
void withoutThePropertyTheEnforcementIsNULL() {
|
||||
assertThat(PqProposalEnforcement.fromSystemConfig()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void aGoodValueArms() {
|
||||
System.setProperty(PqProposalEnforcement.PROPERTY_FORK_BLOCK, "1234567");
|
||||
final PqProposalEnforcement e = PqProposalEnforcement.fromSystemConfig();
|
||||
assertThat(e).isNotNull();
|
||||
assertThat(e.armedAt(1_234_566L)).isFalse();
|
||||
assertThat(e.armedAt(1_234_567L)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void aMISTYPEDValueRefusesLoudly() {
|
||||
for (final String bad : new String[] {"not-a-number", "1_234_567", "-1", "1e6"}) {
|
||||
System.setProperty(PqProposalEnforcement.PROPERTY_FORK_BLOCK, bad);
|
||||
assertThatThrownBy(PqProposalEnforcement::fromSystemConfig)
|
||||
.as("the value '%s'", bad)
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("AERE-PQC-PROPOSAL-ENF-01");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void theENFORCEMENTGateIsNotTheEMISSIONGate() {
|
||||
// Two distinct properties: a fleet can EMIT for months before anything ENFORCES. If they were
|
||||
// one switch, the first node that started emitting would also start refusing - a flag day.
|
||||
assertThat(PqProposalEnforcement.PROPERTY_FORK_BLOCK)
|
||||
.isNotEqualTo("aere.pq.proposalPq.attachBlock");
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,133 @@
|
||||
/*
|
||||
* 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 java.util.Collections.emptyList;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import org.hyperledger.besu.consensus.common.bft.ConsensusRoundIdentifier;
|
||||
import org.hyperledger.besu.consensus.common.bft.PqSignerRegistry;
|
||||
import org.hyperledger.besu.consensus.qbft.core.QbftBlockTestFixture;
|
||||
import org.hyperledger.besu.consensus.qbft.core.messagewrappers.Proposal;
|
||||
import org.hyperledger.besu.consensus.qbft.core.payload.MessageFactory;
|
||||
import org.hyperledger.besu.consensus.qbft.core.types.QbftBlock;
|
||||
import org.hyperledger.besu.consensus.qbft.core.types.QbftBlockCodec;
|
||||
import org.hyperledger.besu.consensus.qbft.core.types.QbftBlockHeader;
|
||||
import org.hyperledger.besu.cryptoservices.NodeKey;
|
||||
import org.hyperledger.besu.cryptoservices.NodeKeyUtils;
|
||||
import org.hyperledger.besu.datatypes.Address;
|
||||
import org.hyperledger.besu.ethereum.core.Util;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import org.apache.tuweni.bytes.Bytes;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
/**
|
||||
* THE WIRING, not the class: does {@link ProposalPayloadValidator} actually CALL the enforcement?
|
||||
*
|
||||
* <p>Same reason the PREPARE twin exists: a class present in the binary, environment variables
|
||||
* visible to the process, and code that never runs is exactly the failure paid for on 2026-08-28.
|
||||
* The registry here REFUSES everything, so this does not measure cryptography (that has its own
|
||||
* test), only whether the decision passes through the hook. The pair is all it takes: the same
|
||||
* signed proposal, once with enforcement and once without.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
public class ProposalPayloadValidatorPqWiringTest {
|
||||
|
||||
private static final long HEIGHT = 1L;
|
||||
|
||||
private final ConsensusRoundIdentifier round = new ConsensusRoundIdentifier((int) HEIGHT, 0);
|
||||
@Mock private QbftBlockCodec blockEncoder;
|
||||
|
||||
private final NodeKey nodeKey = NodeKeyUtils.generate();
|
||||
private final Address proposer = Util.publicKeyToAddress(nodeKey.getPublicKey());
|
||||
private MessageFactory messageFactory;
|
||||
|
||||
@BeforeEach
|
||||
public void setup() {
|
||||
messageFactory = new MessageFactory(nodeKey, blockEncoder);
|
||||
}
|
||||
|
||||
/** A registry that binds no index and verifies nothing. */
|
||||
private static final class EmptyRegistry implements PqSignerRegistry {
|
||||
@Override
|
||||
public Address addressForIndexAtHistoric(final long blockNumber, final int validatorIndex) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Address addressForIndexAtOwnHead(final long blockNumber, final int validatorIndex) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean verifyAtHistoric(
|
||||
final long blockNumber, final int validatorIndex, final Bytes message, final Bytes signature) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean verifyAtOwnHead(
|
||||
final long blockNumber, final int validatorIndex, final Bytes message, final Bytes signature) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private Proposal unsealedProposal() {
|
||||
final QbftBlockHeader header =
|
||||
new QbftBlockHeaderTestFixture().number(round.getSequenceNumber()).buildHeader();
|
||||
final QbftBlock block = new QbftBlockTestFixture().blockHeader(header).build();
|
||||
return messageFactory.createProposal(round, block, Optional.empty(), emptyList(), emptyList());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void withoutEnforcementAnUnsealedProposalISValid() {
|
||||
final ProposalPayloadValidator validator =
|
||||
new ProposalPayloadValidator(proposer, round, null, null);
|
||||
assertThat(validator.validateWithoutBlockValidation(unsealedProposal().getSignedPayload()))
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void withTheEnforcementARMEDTheSameProposalISRefused() {
|
||||
// THE SAME message as above. The only difference is the hook, so a different result means it
|
||||
// really is called. If this still returned true, the enforcement would be dead code.
|
||||
final ProposalPayloadValidator validator =
|
||||
new ProposalPayloadValidator(
|
||||
proposer, round, null, new PqProposalEnforcement(HEIGHT, new EmptyRegistry(), 2800L));
|
||||
assertThat(validator.validateWithoutBlockValidation(unsealedProposal().getSignedPayload()))
|
||||
.isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void withTheEnforcementBELOWItsHeightTheSameProposalISValid() {
|
||||
// The third state, closing the last way of being wrong: a hook that refused regardless of
|
||||
// height would make the binary impossible to deploy. Here the enforcement exists but does not
|
||||
// apply yet.
|
||||
final ProposalPayloadValidator validator =
|
||||
new ProposalPayloadValidator(
|
||||
proposer,
|
||||
round,
|
||||
null,
|
||||
new PqProposalEnforcement(HEIGHT + 1, new EmptyRegistry(), 2800L));
|
||||
assertThat(validator.validateWithoutBlockValidation(unsealedProposal().getSignedPayload()))
|
||||
.isTrue();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user