diff --git a/README.md b/README.md index 054e1ba..f50cfda 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,18 @@ code in this repository. default (`test test ... junk`) used for local test signers. - `package.json` the dependency manifest. +### `contracts/pqc-evidence/`: on-chain equivocation evidence (AIP-22 section 6; added 2026-09-23) + +`PqEquivocationEvidence` records, permanently and without any owner, that ONE validator row of a post-quantum registry epoch sealed +two different consensus messages of the same type for the same height and round. It proves the row into the registry hash pinned on +chain for the epoch in force (an 11-byte read-only getter, slot 0), rebuilds both signed pre-images exactly as the clients do, and +verifies both Falcon-512 seals through the chain's precompile at `0x0AE1`. `contracts/pqc-evidence/test/MockFalcon512.sol` is a +stand-in for that precompile used ONLY by the local tests (`test/evidence/`); real seals are checked against the live precompile. +`test/evidence/ExercitiuEchivoc.java` is the program that produced the drill seals below, with the clients' own signing code. +Measured on the public testnet 28001 on 2026-09-23, not on chain 2800: a drill instance recorded a PREPARE and a COMMIT equivocation +of drill keys (322,404 and 303,103 gas), and an instance for the testnet's own registry epochs proves the rows of the live epoch and +verifies live anchor seals through the contract. The procedure and the addresses are in the AIP-22 stage records (aere-research). + ## Verify ```bash diff --git a/contracts/pqc-evidence/PqEquivocationEvidence.sol b/contracts/pqc-evidence/PqEquivocationEvidence.sol new file mode 100644 index 0000000..86e5990 --- /dev/null +++ b/contracts/pqc-evidence/PqEquivocationEvidence.sol @@ -0,0 +1,269 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity 0.8.23; + +/// @title PqEquivocationEvidence +/// @notice AIP-22 section 6: a permanent, independently verifiable record that ONE validator row sealed, with its post-quantum +/// (Falcon-512) key, two different consensus messages of the same type for the same height and round. Immutable and +/// ownerless: no setter, no admin, no upgrade path. A registry epoch added after deployment needs a new contract. +/// @dev What is verified, all on chain: +/// 1. the row: the v3 row pre-image (AERE-PQ-ROW-3, the canonical bytes of PqRegistryV3.rowPreimage) is parsed for chain id, +/// epoch, row count, index, id and the Falcon-512 key; the id is the one derived from that key; the row hash is proven into +/// the epoch's Merkle root, and the registry hash built from that root equals the value pinned on chain for the epoch in force +/// at the height (the 11-byte read-only getter, slot 0), so the key is the one the chain bound to that index; +/// 2. the two messages: rebuilt on chain from their pre-image fields (PqAnchor.*Message, one ASCII domain per type), they +/// differ, and each seal verifies under the row's key through the live Falcon-512 precompile at 0x0AE1; +/// 3. the record: (id, type, height, round) is recorded once and an event names the index and the id. +/// The object carries the signed PRE-IMAGE FIELDS rather than the full upstream payloads: a seal binds only its pre-image, so +/// nothing else in a payload could be checked here (stated deviation from the AIP-22 text, plan 9.8). +/// Equivocation per type: PROPOSAL and PREPARE, same height and round, different digest; COMMIT (the anchor form COMMIT-1, +/// which carries no round), same height, different block hash; ROUND-CHANGE, same height and target round, different prepared +/// claim. Not in this contract, stated: the second form of section 6 (two conflicting anchor headers). +contract PqEquivocationEvidence { + address public constant FALCON512 = address(0x0AE1); + /// @notice runtime of the pin every epoch must use: PUSH1 0 SLOAD PUSH1 0 MSTORE PUSH1 32 PUSH1 0 RETURN (no SSTORE anywhere) + bytes32 public constant PIN_CODEHASH = keccak256(hex"60005460005260206000f3"); + + uint8 public constant PROPOSAL = 1; + uint8 public constant PREPARE = 2; + uint8 public constant COMMIT = 3; + uint8 public constant ROUNDCHANGE = 4; + + uint64 public immutable chainId; + uint64[] private _bindHeights; + address[] private _pins; + + struct Evidence { + uint8 kind; + uint64 height; + uint64 round; // PROPOSAL/PREPARE: the round; ROUNDCHANGE: the target round; COMMIT: must be 0 + bytes32 digestA; // PROPOSAL/PREPARE: digest; COMMIT: block hash; ROUNDCHANGE: prepared digest (0 when not prepared) + bytes32 digestB; + bool preparedA; // ROUNDCHANGE only + bool preparedB; + uint64 preparedRoundA; // ROUNDCHANGE only + uint64 preparedRoundB; + bytes sealA; // Falcon-512 seal as on the wire: 0x39 || nonce(40) || compressed + bytes sealB; + bytes rowPreimage; // AERE-PQ-ROW-3 pre-image of the sealing row + bytes32[] merkleProof; // sibling path, leaf level first + } + + struct Record { + bytes20 id; + uint32 index; + uint64 bindHeight; + bytes32 messageA; + bytes32 messageB; + address submitter; + uint64 recordedAtBlock; + } + + /// @notice keccak256(abi.encode(id, kind, height, round)) => record + mapping(bytes32 => Record) public records; + uint256 public recordCount; + + event EquivocationRecorded( + bytes20 indexed id, + uint32 indexed index, + uint64 indexed height, + uint8 kind, + uint64 round, + bytes32 messageA, + bytes32 messageB, + address submitter + ); + + error BadEpochs(); + error BadPin(address pin); + error UnknownKind(uint8 kind); + error CommitCarriesRound(); + error NotAConflict(); + error NoEpochAtHeight(uint64 height); + error BadRow(string what); + error RowNotInEpoch(); + error PinMismatch(bytes32 pinned, bytes32 computed); + error SealInvalid(uint8 which); + error AlreadyRecorded(bytes32 key); + + /// @param chainId_ the chain every pre-image names + /// @param bindHeights ascending activation heights of the v3 registry epochs + /// @param pins for each epoch, the read-only pin contract whose slot 0 is the epoch's registry hash + constructor(uint64 chainId_, uint64[] memory bindHeights, address[] memory pins) { + if (bindHeights.length == 0 || bindHeights.length != pins.length) revert BadEpochs(); + for (uint256 i = 0; i < pins.length; i++) { + if (i > 0 && bindHeights[i] <= bindHeights[i - 1]) revert BadEpochs(); + if (pins[i].codehash != PIN_CODEHASH) revert BadPin(pins[i]); + _bindHeights.push(bindHeights[i]); + _pins.push(pins[i]); + } + chainId = chainId_; + } + + /// @notice the epochs this contract judges: (bindHeight, pin) in ascending order + function epochs() external view returns (uint64[] memory, address[] memory) { + return (_bindHeights, _pins); + } + + /// @notice verify and record one equivocation; reverts with the reason if anything does not hold + function submit(Evidence calldata e) external returns (bytes32 key) { + (bytes32 mA, bytes32 mB) = messages(e); + if (mA == mB) revert NotAConflict(); + + (uint64 bind, address pin) = epochAt(e.height); + (bytes20 id, uint32 index, bytes memory pk) = _checkRow(e.rowPreimage, e.merkleProof, bind, pin); + + if (!falconVerify(pk, mA, e.sealA)) revert SealInvalid(1); + if (!falconVerify(pk, mB, e.sealB)) revert SealInvalid(2); + + key = keccak256(abi.encode(id, e.kind, e.height, e.round)); + if (records[key].submitter != address(0)) revert AlreadyRecorded(key); + records[key] = Record(id, index, bind, mA, mB, msg.sender, uint64(block.number)); + recordCount += 1; + emit EquivocationRecorded(id, index, e.height, e.kind, e.round, mA, mB, msg.sender); + } + + /// @notice the two 32-byte messages the seals sign, rebuilt from the pre-image fields exactly as PqAnchor builds them + function messages(Evidence calldata e) public view returns (bytes32 mA, bytes32 mB) { + if (e.kind == PROPOSAL || e.kind == PREPARE) { + bytes memory dom = e.kind == PROPOSAL ? bytes("AERE-PQ-PROPOSAL-1") : bytes("AERE-PQ-PREPARE-1"); + mA = keccak256(_list(bytes.concat(_rlpBytes(dom), _scalar(chainId), _scalar(e.height), _scalar(e.round), _rlpBytes(abi.encodePacked(e.digestA))))); + mB = keccak256(_list(bytes.concat(_rlpBytes(dom), _scalar(chainId), _scalar(e.height), _scalar(e.round), _rlpBytes(abi.encodePacked(e.digestB))))); + } else if (e.kind == COMMIT) { + if (e.round != 0) revert CommitCarriesRound(); + bytes memory dom = bytes("AERE-PQ-COMMIT-1"); + mA = keccak256(_list(bytes.concat(_rlpBytes(dom), _scalar(chainId), _scalar(e.height), _rlpBytes(abi.encodePacked(e.digestA))))); + mB = keccak256(_list(bytes.concat(_rlpBytes(dom), _scalar(chainId), _scalar(e.height), _rlpBytes(abi.encodePacked(e.digestB))))); + } else if (e.kind == ROUNDCHANGE) { + mA = _roundChange(e.height, e.round, e.preparedA, e.preparedRoundA, e.digestA); + mB = _roundChange(e.height, e.round, e.preparedB, e.preparedRoundB, e.digestB); + } else { + revert UnknownKind(e.kind); + } + } + + function _roundChange(uint64 height, uint64 target, bool prepared, uint64 preparedRound, bytes32 digest) internal view returns (bytes32) { + // the bare form (PqAnchor.roundChangeMessage without a prepared claim): hasPrepared 0, preparedRound 0, EMPTY digest + bytes memory d = prepared ? abi.encodePacked(digest) : bytes(""); + uint64 pr = prepared ? preparedRound : 0; + return keccak256(_list(bytes.concat( + _rlpBytes(bytes("AERE-PQ-ROUNDCHANGE-1")), _scalar(chainId), _scalar(height), _scalar(target), + _scalar(prepared ? 1 : 0), _scalar(pr), _rlpBytes(d)))); + } + + /// @notice the epoch in force at a height: the last one whose bindHeight is at or below it + function epochAt(uint64 height) public view returns (uint64 bind, address pin) { + uint256 n = _bindHeights.length; + for (uint256 i = n; i > 0; i--) { + if (_bindHeights[i - 1] <= height) return (_bindHeights[i - 1], _pins[i - 1]); + } + revert NoEpochAtHeight(height); + } + + /// @notice read-only: does this row pre-image, with this Merkle path, belong to the epoch pinned for `height`? Reverts with the + /// reason if not; returns the row's id and index if so. The same check submit() makes, open to anyone as a call. + function rowAt(uint64 height, bytes calldata rowPreimage, bytes32[] calldata merkleProof) + external + view + returns (bytes20 id, uint32 index) + { + (uint64 bind, address pin) = epochAt(height); + (id, index, ) = _checkRow(rowPreimage, merkleProof, bind, pin); + } + + /// @notice the registry-v3 row check: parse, derive the id, prove the row into the pinned registry hash; returns id, index, key + function _checkRow(bytes calldata p, bytes32[] calldata proof, uint64 bind, address pin) + internal + view + returns (bytes20 id, uint32 index, bytes memory falconPk) + { + if (p.length < 60 || bytes13(p[0:13]) != bytes13("AERE-PQ-ROW-3") || uint8(p[13]) != 3) revert BadRow("prefix"); + if (uint64(bytes8(p[14:22])) != chainId) revert BadRow("chain id"); + if (uint64(bytes8(p[22:30])) != bind) revert RowNotInEpoch(); + uint32 count = uint32(bytes4(p[30:34])); + index = uint32(bytes4(p[34:38])); + id = bytes20(p[38:58]); + if (count == 0 || index >= count) revert BadRow("index"); + uint256 nKeys = uint8(p[58]); + uint256 o = 59; + uint256 lastWire = 0; + for (uint256 k = 0; k < nKeys; k++) { + if (o + 5 > p.length) revert BadRow("key header"); + uint256 wire = uint8(p[o]); + if (wire <= lastWire) revert BadRow("key order"); + lastWire = wire; + uint256 pkLen = uint32(bytes4(p[o + 1:o + 5])); + uint256 pkEnd = o + 5 + pkLen; + if (pkEnd + 4 > p.length) revert BadRow("key length"); + if (wire == 1) { + if (pkLen != 897) revert BadRow("falcon key length"); + falconPk = p[o + 5:pkEnd]; + } + uint256 popLen = uint32(bytes4(p[pkEnd:pkEnd + 4])); + o = pkEnd + 4 + popLen; + if (o > p.length) revert BadRow("pop length"); + } + if (o >= p.length) revert BadRow("transport flag"); + uint8 hasTransport = uint8(p[o]); + if (!((hasTransport == 0 && p.length == o + 1) || (hasTransport == 1 && p.length == o + 21))) revert BadRow("transport"); + if (falconPk.length == 0) revert BadRow("no falcon key"); + // The id is NOT re-derived from the key here: a ROTATED row keeps the id of its first registration (AIP-22 stage 3b), so on a + // live epoch the id of a rotated row is not the derivation of its current key (measured 2026-09-23 on 28001: row 2 of epoch + // 3,097,600). The id is inside the row hash, which is proven below into the registry hash the chain pinned, and the registry + // itself binds each id (derivation for a new id, the previous keys' rotation signatures for a rotated one; PqRegistryV3.load). + + // Merkle: node = keccak256(0x01 || l || r); an odd node is paired with itself; the depth follows from count + uint256 depth = 0; + for (uint256 n = count; n > 1; n = (n + 1) / 2) depth++; + if (proof.length != depth) revert BadRow("proof length"); + bytes32 acc = keccak256(p); + uint256 i = index; + for (uint256 d = 0; d < depth; d++) { + acc = i % 2 == 0 ? keccak256(abi.encodePacked(bytes1(0x01), acc, proof[d])) : keccak256(abi.encodePacked(bytes1(0x01), proof[d], acc)); + i /= 2; + } + bytes32 computed = keccak256(abi.encodePacked("AERE-PQ-REGISTRY-3", chainId, bind, count, acc)); + (bool ok, bytes memory ret) = pin.staticcall(""); + if (!ok || ret.length != 32) revert BadPin(pin); + bytes32 pinned = abi.decode(ret, (bytes32)); + if (pinned != computed) revert PinMismatch(pinned, computed); + } + + /// @notice one Falcon-512 seal through the live precompile: 0x39 || nonce || compressed on the wire becomes + /// pk(897) || u16(len esig) || nonce(40) || message || esig, esig = 0x29 || compressed (SPEC 4.2; proven on 28001) + function falconVerify(bytes memory pk, bytes32 message, bytes calldata seal) public view returns (bool) { + if (pk.length != 897 || seal.length <= 41 || uint8(seal[0]) != 0x39) return false; + bytes memory esig = bytes.concat(bytes1(0x29), seal[41:]); + if (esig.length > type(uint16).max) return false; + bytes memory input = bytes.concat(pk, bytes2(uint16(esig.length)), seal[1:41], message, esig); + (bool ok, bytes memory ret) = FALCON512.staticcall(input); + return ok && ret.length >= 32 && ret[31] == 0x01; + } + + // ---- RLP, the four shapes PqAnchor writes: bytes, a long scalar (minimal big-endian, 0 is empty), a list ---- + + function _rlpLen(uint256 len, uint8 offset) private pure returns (bytes memory) { + if (len <= 55) return abi.encodePacked(uint8(offset + len)); + bytes memory l = _minimal(len); + return bytes.concat(abi.encodePacked(uint8(offset + 55 + l.length)), l); + } + + function _minimal(uint256 v) private pure returns (bytes memory out) { + uint256 n = 0; + for (uint256 t = v; t > 0; t >>= 8) n++; + out = new bytes(n); + for (uint256 i = 0; i < n; i++) out[n - 1 - i] = bytes1(uint8(v >> (8 * i))); + } + + function _rlpBytes(bytes memory b) private pure returns (bytes memory) { + if (b.length == 1 && uint8(b[0]) < 0x80) return b; + return bytes.concat(_rlpLen(b.length, 0x80), b); + } + + function _scalar(uint256 v) private pure returns (bytes memory) { + return _rlpBytes(_minimal(v)); + } + + function _list(bytes memory payload) private pure returns (bytes memory) { + return bytes.concat(_rlpLen(payload.length, 0xc0), payload); + } +} diff --git a/contracts/pqc-evidence/test/MockFalcon512.sol b/contracts/pqc-evidence/test/MockFalcon512.sol new file mode 100644 index 0000000..4ec4d22 --- /dev/null +++ b/contracts/pqc-evidence/test/MockFalcon512.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity 0.8.23; + +/// @title MockFalcon512 (TESTS ONLY; its runtime is placed at 0x0AE1 on the local test chain) +/// @notice A deterministic stand-in for the live Falcon-512 precompile, so the local probes can measure the framing, the pre-images, +/// the Merkle path and every refusal of PqEquivocationEvidence without a Falcon implementation in the EVM. It reads the input +/// EXACTLY as the live precompile does (pk(897) || u16(len esig) || nonce(40) || message || esig, esig = 0x29 || body) and +/// answers 1 iff the 32-byte body equals keccak256(pk || message). Real Falcon seals are measured against the live +/// precompile on testnet 28001, never with this contract. +contract MockFalcon512 { + fallback(bytes calldata input) external returns (bytes memory) { + if (input.length < 897 + 2 + 40 + 1) return abi.encode(uint256(0)); + uint256 esigLen = uint16(bytes2(input[897:899])); + if (input.length < 939 + esigLen) return abi.encode(uint256(0)); + uint256 msgLen = input.length - 939 - esigLen; + bytes calldata esig = input[939 + msgLen:]; + if (esig.length != 33 || uint8(esig[0]) != 0x29) return abi.encode(uint256(0)); + bytes32 want = keccak256(bytes.concat(input[0:897], input[939:939 + msgLen])); + return abi.encode(uint256(bytes32(esig[1:33]) == want ? 1 : 0)); + } +} diff --git a/test/evidence/ExercitiuEchivoc.java b/test/evidence/ExercitiuEchivoc.java new file mode 100644 index 0000000..8dfbae4 --- /dev/null +++ b/test/evidence/ExercitiuEchivoc.java @@ -0,0 +1,97 @@ +// AIP-22 stage 6, the DRILL for the live run on testnet 28001 (2026-09-23). It builds a registry-v3 epoch of THREE rows whose Falcon-512 +// keys are generated here, in memory, for this run only (drill keys: never written to disk, never printed; the epoch names chain 28099, +// the rehearsal chain, so no record of the drill can be read as one about a validator of 28001 or 2800), and has ONE row seal two +// conflicting PREPARE messages and two conflicting COMMIT messages, with the same code the clients use (PqAnchor.*Message, +// FalconSealScheme through BouncyCastle, PqRegistryV3 row pre-image, Merkle tree and registry hash). Everything it prints is public: +// row pre-images, Merkle paths, registry hash, the messages and the seals, plus a positive and a negative control of each seal under the +// same verifier the clients use. The contract on 28001 then checks the same seals with the live precompile. +// javac -cp "/lib/*" ExercitiuEchivoc.java && java -cp ".:/lib/*" ExercitiuEchivoc > exercitiu.json +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import org.apache.tuweni.bytes.Bytes; +import org.apache.tuweni.bytes.Bytes32; +import org.bouncycastle.pqc.crypto.falcon.FalconKeyGenerationParameters; +import org.bouncycastle.pqc.crypto.falcon.FalconKeyPairGenerator; +import org.bouncycastle.pqc.crypto.falcon.FalconParameters; +import org.bouncycastle.pqc.crypto.falcon.FalconPrivateKeyParameters; +import org.hyperledger.besu.consensus.common.bft.FalconSealScheme; +import org.hyperledger.besu.consensus.common.bft.PqAnchor; +import org.hyperledger.besu.consensus.common.bft.PqRegistryV3; +import org.hyperledger.besu.consensus.common.bft.SealSchemes; + +public final class ExercitiuEchivoc { + static final long CHAIN = 28099L; + static final long BIND = 1000L; + static final int COUNT = 3; + static final int SIGNER = 1; + static final long HEIGHT = 2000L; + static final int ROUND = 2; + + static String hx(byte[] b) { return Bytes.wrap(b).toHexString(); } + + public static void main(String[] a) { + final SecureRandom rng = new SecureRandom(); + final FalconSealScheme falcon = (FalconSealScheme) SealSchemes.FALCON_512; + final List sks = new ArrayList<>(); + final List std = new ArrayList<>(); + final List pre = new ArrayList<>(); + final List leaves = new ArrayList<>(); + for (int i = 0; i < COUNT; i++) { + final FalconKeyPairGenerator g = new FalconKeyPairGenerator(); + g.init(new FalconKeyGenerationParameters(rng, FalconParameters.falcon_512)); + final FalconPrivateKeyParameters sk = (FalconPrivateKeyParameters) g.generateKeyPair().getPrivate(); + final byte[] h = sk.getPublicKey(); + final byte[] s = new byte[PqRegistryV3.FALCON_512_PK_LENGTH]; + s[0] = PqRegistryV3.FALCON_512_PK_HEADER; + System.arraycopy(h, 0, s, 1, h.length); + final byte[] id = PqRegistryV3.idOf(s); + final byte[] pop = falcon.signWithParams(sk, PqRegistryV3.popMessage(CHAIN, BIND, COUNT, i, id, SealSchemes.FALCON_512.wireId(), s).toArrayUnsafe()).orElseThrow(); + final TreeMap keys = new TreeMap<>(); + keys.put(SealSchemes.FALCON_512.wireId() & 0xff, new PqRegistryV3.Key(SealSchemes.FALCON_512, s, pop)); + final PqRegistryV3.Row row = new PqRegistryV3.Row(i, id, keys, null, Map.of(), null, null); + final byte[] p = PqRegistryV3.rowPreimage(CHAIN, BIND, COUNT, row); + sks.add(sk); std.add(s); pre.add(p); leaves.add(PqRegistryV3.rowHash(CHAIN, BIND, COUNT, row)); + } + final Bytes32 root = PqRegistryV3.merkleRoot(leaves); + final Bytes32 rh = PqRegistryV3.registryHash(CHAIN, BIND, COUNT, root); + final Bytes32 dA = Bytes32.random(rng), dB = Bytes32.random(rng), hA = Bytes32.random(rng), hB = Bytes32.random(rng); + final Bytes32 pA = PqAnchor.prepareMessage(CHAIN, HEIGHT, ROUND, dA), pB = PqAnchor.prepareMessage(CHAIN, HEIGHT, ROUND, dB); + final Bytes32 cA = PqAnchor.commitMessage(CHAIN, HEIGHT, hA), cB = PqAnchor.commitMessage(CHAIN, HEIGHT, hB); + final FalconPrivateKeyParameters sk = sks.get(SIGNER); + final byte[] spA = falcon.signWithParams(sk, pA.toArrayUnsafe()).orElseThrow(), spB = falcon.signWithParams(sk, pB.toArrayUnsafe()).orElseThrow(); + final byte[] scA = falcon.signWithParams(sk, cA.toArrayUnsafe()).orElseThrow(), scB = falcon.signWithParams(sk, cB.toArrayUnsafe()).orElseThrow(); + final byte[] reg = sks.get(SIGNER).getPublicKey(); + final byte[] other = sks.get((SIGNER + 1) % COUNT).getPublicKey(); + // controls under the clients' own verifier: each seal verifies under its row's key and NOT under another row's, nor over the other message + final boolean ok = falcon.verifyRaw(reg, pA.toArrayUnsafe(), spA) && falcon.verifyRaw(reg, pB.toArrayUnsafe(), spB) + && falcon.verifyRaw(reg, cA.toArrayUnsafe(), scA) && falcon.verifyRaw(reg, cB.toArrayUnsafe(), scB); + final boolean neg = !falcon.verifyRaw(other, pA.toArrayUnsafe(), spA) && !falcon.verifyRaw(reg, pB.toArrayUnsafe(), spA); + final StringBuilder o = new StringBuilder(); + o.append("{\n \"what\": \"AIP-22 stage 6 drill: chain 28099 epoch of drill keys generated in memory for this run\",\n"); + o.append(" \"chainId\": ").append(CHAIN).append(", \"bindHeight\": ").append(BIND).append(", \"count\": ").append(COUNT).append(",\n"); + o.append(" \"root\": \"").append(root.toHexString()).append("\", \"registryHash\": \"").append(rh.toHexString()).append("\",\n"); + o.append(" \"rows\": ["); + for (int i = 0; i < COUNT; i++) o.append(i > 0 ? ", " : "").append('"').append(hx(pre.get(i))).append('"'); + o.append("],\n \"proofs\": ["); + for (int i = 0; i < COUNT; i++) { + o.append(i > 0 ? ", " : "").append('['); + final List pr = PqRegistryV3.merkleProof(leaves, i); + for (int j = 0; j < pr.size(); j++) o.append(j > 0 ? ", " : "").append('"').append(pr.get(j).toHexString()).append('"'); + o.append(']'); + } + o.append("],\n \"signer\": ").append(SIGNER).append(", \"height\": ").append(HEIGHT).append(", \"round\": ").append(ROUND).append(",\n"); + o.append(" \"prepare\": {\"digestA\": \"").append(dA.toHexString()).append("\", \"digestB\": \"").append(dB.toHexString()) + .append("\", \"messageA\": \"").append(pA.toHexString()).append("\", \"messageB\": \"").append(pB.toHexString()) + .append("\", \"sealA\": \"").append(hx(spA)).append("\", \"sealB\": \"").append(hx(spB)).append("\"},\n"); + o.append(" \"commit\": {\"hashA\": \"").append(hA.toHexString()).append("\", \"hashB\": \"").append(hB.toHexString()) + .append("\", \"messageA\": \"").append(cA.toHexString()).append("\", \"messageB\": \"").append(cB.toHexString()) + .append("\", \"sealA\": \"").append(hx(scA)).append("\", \"sealB\": \"").append(hx(scB)).append("\"},\n"); + o.append(" \"otherRowSeal\": \"").append(hx(falcon.signWithParams(sks.get((SIGNER + 1) % COUNT), pB.toArrayUnsafe()).orElseThrow())).append("\",\n"); + o.append(" \"controlsUnderClientVerifier\": {\"positive\": ").append(ok).append(", \"negative\": ").append(neg).append("}\n}\n"); + System.out.print(o); + if (!ok || !neg) System.exit(1); + } +} diff --git a/test/evidence/PqEquivocationEvidence.test.js b/test/evidence/PqEquivocationEvidence.test.js new file mode 100644 index 0000000..6bb12e2 --- /dev/null +++ b/test/evidence/PqEquivocationEvidence.test.js @@ -0,0 +1,185 @@ +// AIP-22 stage 6: PqEquivocationEvidence, local probes (2026-09-23). +// A registry v3 epoch of THREE rows (odd, so the Merkle self-pairing is exercised) is built here with the canonical bytes of +// PqRegistryV3.rowPreimage (the same code as the published registry-v3.mjs), pinned in the 11-byte getter, and the live Falcon-512 +// precompile is stood in by MockFalcon512 at 0x0AE1 (a "seal" is keccak256(pk || message) framed like a real one). Every refusal is +// asserted by its REASON (custom error), not by a bare revert. Real Falcon seals are measured on testnet 28001, not here. +const { expect } = require("chai"); +const { ethers, network } = require("hardhat"); + +const CHAIN = 28001n; +const BIND = 3097600n; +const PREC = "0x0000000000000000000000000000000000000ae1"; +const K = (b) => Buffer.from(ethers.getBytes(ethers.keccak256(b))); +const u32 = (v) => { const b = Buffer.alloc(4); b.writeUInt32BE(Number(v)); return b; }; +const u64 = (v) => { const b = Buffer.alloc(8); b.writeBigUInt64BE(BigInt(v)); return b; }; + +function falconPk(seed) { const pk = Buffer.alloc(897); pk[0] = 0x09; for (let i = 1; i < 897; i++) pk[i] = (seed * 31 + i * 7) & 0xff; return pk; } +const idOf = (pk) => K(Buffer.concat([Buffer.from([0x50, 0x20]), pk])).subarray(12, 32); +// idOverride: a ROTATED row keeps the id of its first registration, so its id is not the derivation of its current key (AIP-22 stage 3b) +function rowPreimage(chainId, bind, count, index, pk, withSlh, transport, idOverride) { + const parts = [Buffer.from("AERE-PQ-ROW-3", "ascii"), Buffer.from([3]), u64(chainId), u64(bind), u32(count), u32(index), idOverride || idOf(pk)]; + const keys = [[1, pk, Buffer.alloc(666, 0x39)]]; + if (withSlh) keys.push([2, Buffer.alloc(32, 0x5a + index), Buffer.alloc(7856, 0x11)]); + parts.push(Buffer.from([keys.length])); + for (const [w, k, pop] of keys) parts.push(Buffer.from([w]), u32(k.length), k, u32(pop.length), pop); + parts.push(transport ? Buffer.concat([Buffer.from([1]), transport]) : Buffer.from([0])); + return Buffer.concat(parts); +} +const node = (l, r) => K(Buffer.concat([Buffer.from([1]), l, r])); +function merkle(leaves) { + const levels = [leaves]; + while (levels[levels.length - 1].length > 1) { + const lv = levels[levels.length - 1], nx = []; + for (let i = 0; i < lv.length; i += 2) nx.push(node(lv[i], lv[i + 1] ?? lv[i])); + levels.push(nx); + } + return levels; +} +function proof(levels, index) { + const p = []; let i = index; + for (let d = 0; d < levels.length - 1; d++) { const lv = levels[d]; const s = i % 2 === 0 ? i + 1 : i - 1; p.push(lv[s] ?? lv[i]); i = Math.floor(i / 2); } + return p.map((x) => "0x" + x.toString("hex")); +} +const registryHash = (chainId, bind, count, root) => + K(Buffer.concat([Buffer.from("AERE-PQ-REGISTRY-3", "ascii"), u64(chainId), u64(bind), u32(count), root])); + +// RLP and the PqAnchor pre-images, written from the Java source (PqAnchor.prepareMessage / proposalMessage / commitMessage / roundChangePreimage) +const rlpLen = (n, off) => { if (n <= 55) return Buffer.from([off + n]); let h = n.toString(16); if (h.length % 2) h = "0" + h; const l = Buffer.from(h, "hex"); return Buffer.concat([Buffer.from([off + 55 + l.length]), l]); }; +const rb = (b) => (b.length === 1 && b[0] < 0x80 ? b : Buffer.concat([rlpLen(b.length, 0x80), b])); +const sc = (v) => { v = BigInt(v); if (v === 0n) return rb(Buffer.alloc(0)); let h = v.toString(16); if (h.length % 2) h = "0" + h; return rb(Buffer.from(h, "hex")); }; +const list = (...x) => { const p = Buffer.concat(x); return Buffer.concat([rlpLen(p.length, 0xc0), p]); }; +const A = (s) => rb(Buffer.from(s, "ascii")); +const H = (h) => rb(Buffer.from(ethers.getBytes(h))); +const msgPR = (dom, h, r, d) => K(list(A(dom), sc(CHAIN), sc(h), sc(r), H(d))); +const msgCommit = (h, hash) => K(list(A("AERE-PQ-COMMIT-1"), sc(CHAIN), sc(h), H(hash))); +const msgRC = (h, t, prep, pr, d) => K(list(A("AERE-PQ-ROUNDCHANGE-1"), sc(CHAIN), sc(h), sc(t), sc(prep ? 1 : 0), sc(prep ? pr : 0), prep ? H(d) : rb(Buffer.alloc(0)))); +// a mock "seal": 0x39 || nonce(40) || keccak256(pk || message) +const seal = (pk, m) => "0x" + Buffer.concat([Buffer.from([0x39]), Buffer.alloc(40, 0x77), K(Buffer.concat([pk, m]))]).toString("hex"); + +describe("PqEquivocationEvidence (AIP-22 stage 6)", function () { + let ev, pin, pks, pre, levels, root, count, ROT, ids; + const D1 = "0x" + "11".repeat(32), D2 = "0x" + "22".repeat(32); + + async function deployPin(hash) { + const [s] = await ethers.getSigners(); + const init = "0x7f" + hash.toString("hex") + "600055600b80602f6000396000f3" + "60005460005260206000f3"; + const tx = await s.sendTransaction({ data: init }); + const r = await tx.wait(); + return r.contractAddress; + } + + before(async function () { + const Mock = await ethers.getContractFactory("MockFalcon512"); + const m = await Mock.deploy(); await m.waitForDeployment(); + await network.provider.send("hardhat_setCode", [PREC, await ethers.provider.getCode(await m.getAddress())]); + count = 3n; + pks = [falconPk(1), falconPk(2), falconPk(3)]; + // row 0 is a ROTATED row (its id is another key's derivation), as row 2 of the live epoch 3,097,600 on 28001 + ROT = idOf(falconPk(99)); + ids = [ROT, idOf(pks[1]), idOf(pks[2])]; + pre = [rowPreimage(CHAIN, BIND, count, 0, pks[0], true, null, ROT), rowPreimage(CHAIN, BIND, count, 1, pks[1], true, Buffer.alloc(20, 0xab)), rowPreimage(CHAIN, BIND, count, 2, pks[2], false, null)]; + levels = merkle(pre.map((p) => K(p))); + root = levels[levels.length - 1][0]; + pin = await deployPin(registryHash(CHAIN, BIND, count, root)); + const F = await ethers.getContractFactory("PqEquivocationEvidence"); + ev = await F.deploy(CHAIN, [BIND], [pin]); await ev.waitForDeployment(); + }); + + const base = (kind, h, r, dA, dB, idx, sA, sB, extra = {}) => ({ + kind, height: h, round: r, digestA: dA, digestB: dB, preparedA: false, preparedB: false, preparedRoundA: 0, preparedRoundB: 0, + sealA: sA, sealB: sB, rowPreimage: "0x" + pre[idx].toString("hex"), merkleProof: proof(levels, idx), ...extra, + }); + + it("the pin is the 11-byte read-only getter and returns the registry hash", async function () { + expect(await ethers.provider.getCode(pin)).to.equal("0x60005460005260206000f3"); + expect(await ethers.provider.call({ to: pin })).to.equal("0x" + registryHash(CHAIN, BIND, count, root).toString("hex")); + }); + + it("rebuilds the four pre-images exactly as PqAnchor writes them", async function () { + const h = 3131500n; + const e1 = base(2, h, 3, D1, D2, 1, "0x", "0x"); + const [a, b] = await ev.messages(e1); + expect(a).to.equal("0x" + msgPR("AERE-PQ-PREPARE-1", h, 3, D1).toString("hex")); + expect(b).to.equal("0x" + msgPR("AERE-PQ-PREPARE-1", h, 3, D2).toString("hex")); + const [p] = await ev.messages(base(1, h, 0, D1, D2, 1, "0x", "0x")); + expect(p).to.equal("0x" + msgPR("AERE-PQ-PROPOSAL-1", h, 0, D1).toString("hex")); + const [c] = await ev.messages(base(3, h, 0, D1, D2, 1, "0x", "0x")); + expect(c).to.equal("0x" + msgCommit(h, D1).toString("hex")); + const [r1, r2] = await ev.messages(base(4, h, 2, D1, D2, 1, "0x", "0x", { preparedA: false, preparedB: true, preparedRoundB: 1 })); + expect(r1).to.equal("0x" + msgRC(h, 2, false, 0, D1).toString("hex")); + expect(r2).to.equal("0x" + msgRC(h, 2, true, 1, D2).toString("hex")); + }); + + it("records a PREPARE equivocation of one row, once, and names its index and id", async function () { + const h = 3131500n, r = 3n; + const e = base(2, h, r, D1, D2, 1, seal(pks[1], msgPR("AERE-PQ-PREPARE-1", h, r, D1)), seal(pks[1], msgPR("AERE-PQ-PREPARE-1", h, r, D2))); + await expect(ev.submit(e)).to.emit(ev, "EquivocationRecorded"); + const key = ethers.keccak256(ethers.AbiCoder.defaultAbiCoder().encode(["bytes20", "uint8", "uint64", "uint64"], ["0x" + idOf(pks[1]).toString("hex"), 2, h, r])); + const rec = await ev.records(key); + expect(rec.id).to.equal("0x" + idOf(pks[1]).toString("hex")); + expect(rec.index).to.equal(1n); + expect(rec.bindHeight).to.equal(BIND); + expect(await ev.recordCount()).to.equal(1n); + await expect(ev.submit(e)).to.be.revertedWithCustomError(ev, "AlreadyRecorded"); + }); + + it("records PROPOSAL, COMMIT and ROUND-CHANGE equivocations (last row of an odd tree: self-paired leaf)", async function () { + const h = 3131600n; + await expect(ev.submit(base(1, h, 1, D1, D2, 2, seal(pks[2], msgPR("AERE-PQ-PROPOSAL-1", h, 1, D1)), seal(pks[2], msgPR("AERE-PQ-PROPOSAL-1", h, 1, D2))))).to.emit(ev, "EquivocationRecorded"); + await expect(ev.submit(base(3, h, 0, D1, D2, 0, seal(pks[0], msgCommit(h, D1)), seal(pks[0], msgCommit(h, D2))))).to.emit(ev, "EquivocationRecorded"); + const x = { preparedA: false, preparedB: true, preparedRoundB: 1 }; + await expect(ev.submit(base(4, h, 2, D1, D2, 0, seal(pks[0], msgRC(h, 2, false, 0, D1)), seal(pks[0], msgRC(h, 2, true, 1, D2)), x))).to.emit(ev, "EquivocationRecorded"); + expect(await ev.recordCount()).to.equal(4n); + }); + + it("refuses each way of not being an equivocation, by its reason", async function () { + const h = 3131700n, r = 0n; + const mA = msgPR("AERE-PQ-PREPARE-1", h, r, D1), mB = msgPR("AERE-PQ-PREPARE-1", h, r, D2); + // the same message twice is not a conflict + await expect(ev.submit(base(2, h, r, D1, D1, 1, seal(pks[1], mA), seal(pks[1], mA)))).to.be.revertedWithCustomError(ev, "NotAConflict"); + // the second seal by ANOTHER row's key: one validator did not sign both + await expect(ev.submit(base(2, h, r, D1, D2, 1, seal(pks[1], mA), seal(pks[0], mB)))).to.be.revertedWithCustomError(ev, "SealInvalid").withArgs(2); + // a seal over another round + await expect(ev.submit(base(2, h, r, D1, D2, 1, seal(pks[1], msgPR("AERE-PQ-PREPARE-1", h, 1, D1)), seal(pks[1], mB)))).to.be.revertedWithCustomError(ev, "SealInvalid").withArgs(1); + // a seal of the PROPOSAL domain offered as a PREPARE + await expect(ev.submit(base(2, h, r, D1, D2, 1, seal(pks[1], msgPR("AERE-PQ-PROPOSAL-1", h, r, D1)), seal(pks[1], mB)))).to.be.revertedWithCustomError(ev, "SealInvalid").withArgs(1); + // a Merkle path that does not reach the pinned root + const bad = base(2, h, r, D1, D2, 1, seal(pks[1], mA), seal(pks[1], mB)); bad.merkleProof = [...bad.merkleProof]; bad.merkleProof[0] = "0x" + "00".repeat(32); + await expect(ev.submit(bad)).to.be.revertedWithCustomError(ev, "PinMismatch"); + // a row claiming the index of another row (the path of row 1 used for row 0's preimage) + const swap = base(2, h, r, D1, D2, 0, seal(pks[0], mA), seal(pks[0], mB)); swap.merkleProof = proof(levels, 1); + await expect(ev.submit(swap)).to.be.revertedWithCustomError(ev, "PinMismatch"); + // a proof of the wrong depth + const short = base(2, h, r, D1, D2, 1, seal(pks[1], mA), seal(pks[1], mB)); short.merkleProof = short.merkleProof.slice(1); + await expect(ev.submit(short)).to.be.revertedWithCustomError(ev, "BadRow").withArgs("proof length"); + // a row of another chain, another epoch, and an id not derived from its key + const other = (mut) => { const p = Buffer.from(pre[1]); mut(p); const e = base(2, h, r, D1, D2, 1, seal(pks[1], mA), seal(pks[1], mB)); e.rowPreimage = "0x" + p.toString("hex"); return e; }; + await expect(ev.submit(other((p) => u64(28002).copy(p, 14)))).to.be.revertedWithCustomError(ev, "BadRow").withArgs("chain id"); + await expect(ev.submit(other((p) => u64(BIND + 1n).copy(p, 22)))).to.be.revertedWithCustomError(ev, "RowNotInEpoch"); + // the id is bound by the REGISTRY (inside the row hash), not re-derived on chain: a changed id no longer proves into the pin + await expect(ev.submit(other((p) => { p[38] ^= 1; }))).to.be.revertedWithCustomError(ev, "PinMismatch"); + await expect(ev.submit(other((p) => { p[0] = 0x42; }))).to.be.revertedWithCustomError(ev, "BadRow").withArgs("prefix"); + // a COMMIT that names a round, an unknown type, a height before the first epoch + await expect(ev.submit(base(3, h, 1, D1, D2, 1, "0x", "0x"))).to.be.revertedWithCustomError(ev, "CommitCarriesRound"); + await expect(ev.submit(base(9, h, r, D1, D2, 1, "0x", "0x"))).to.be.revertedWithCustomError(ev, "UnknownKind").withArgs(9); + await expect(ev.submit(base(2, BIND - 1n, r, D1, D2, 1, seal(pks[1], mA), seal(pks[1], mB)))).to.be.revertedWithCustomError(ev, "NoEpochAtHeight"); + expect(await ev.recordCount()).to.equal(4n); + }); + + it("rowAt: each row proves into the pinned epoch as a plain call, and a tampered row does not", async function () { + for (let i = 0; i < 3; i++) { + const [id, index] = await ev.rowAt(BIND + 5n, "0x" + pre[i].toString("hex"), proof(levels, i)); + expect(id).to.equal("0x" + ids[i].toString("hex")); + expect(index).to.equal(BigInt(i)); + } + const t = Buffer.from(pre[2]); t[t.length - 1] ^= 1; + await expect(ev.rowAt(BIND + 5n, "0x" + t.toString("hex"), proof(levels, 2))).to.be.revertedWithCustomError(ev, "BadRow"); + }); + + it("will not be built on a pin that is not the read-only getter, nor on unordered epochs", async function () { + const F = await ethers.getContractFactory("PqEquivocationEvidence"); + await expect(F.deploy(CHAIN, [BIND], [await ev.getAddress()])).to.be.revertedWithCustomError(F, "BadPin"); + await expect(F.deploy(CHAIN, [BIND, BIND], [pin, pin])).to.be.revertedWithCustomError(F, "BadEpochs"); + await expect(F.deploy(CHAIN, [], [])).to.be.revertedWithCustomError(F, "BadEpochs"); + }); +});