// SPDX-License-Identifier: MIT pragma solidity 0.8.23; /** * @title AereThresholdPQCRegistry, an on-chain t-of-n POST-QUANTUM threshold * authorization primitive over AERE's LIVE PQC precompiles (chain 2800) * * @notice A committee of n post-quantum signers, each with a registered NIST PQC * public key, authorizes a message only when >= t DISTINCT committee members * each PQC-sign the SAME domain-separated challenge, with every signature * verified in full, on-chain, by the live PQC precompile for the committee's * scheme. This gives genuine post-quantum t-of-n authority: every leg is * quantum-resistant, no single member can authorize alone, and no member can * be double-counted. This contract holds NO funds; it is verification only. * * @dev THE HONEST BOUNDARY, read this before trusting or describing anything. * * 1. THIS IS A THRESHOLD MULTISIG OF INDEPENDENT PQC SIGNATURES, NOT A SINGLE * AGGREGATE THRESHOLD-PQC SIGNATURE. A valid authorization here is t distinct * PQC signatures, each verified separately by the precompile. It is NOT one * short signature proving ">= t of n signed" (as a threshold-BLS or a true * threshold-lattice signature would be). A single-aggregate threshold-PQC * signature over ML-DSA / Falcon is an OPEN RESEARCH problem: lattice / * hash-based threshold signatures are immature, unstandardized, and have no * audited production library or matching on-chain verifier. See * docs/THRESHOLD-PQC-2026-07-12.md for the full research summary. Do NOT * describe this as a single aggregate threshold-PQC signature, and do NOT * describe it as post-quantum consensus (AERE consensus is classical ECDSA * QBFT). What IS true and new: each of the t authorizing legs is a real NIST * PQC signature verified on-chain, so the t-of-n authority is quantum-durable. * * 2. WHY THIS IS STRICTLY STRONGER THAN THE ECDSA THRESHOLD REGISTRY ON-CHAIN. * In AereThresholdRegistry (threshold ECDSA), an on-chain t-of-n signature is * byte-identical to a single-key `ecrecover`, so the chain literally cannot * tell that t members participated: "t-of-n" lives entirely off-chain. HERE, * by contrast, the chain verifies t SEPARATE PQC signatures against t DISTINCT * registered public keys, so the t-of-n participation is proven ON-CHAIN. The * trade-off is cost: t independent precompile verifications instead of one * aggregate check, which bounds the practical (scheme, t) ceiling (see below). * * 3. LIVE PRECOMPILES (chain 2800), single-signer verifiers this contract calls: * scheme 1 = Falcon-512 at 0x...0AE1 (cheapest verify: 40,000 gas) * scheme 2 = Falcon-1024 at 0x...0AE2 (75,000 gas) * scheme 3 = ML-DSA-44 at 0x...0AE3 (55,000 gas, FIPS 204) * scheme 4 = SLH-DSA-128s at 0x...0AE4 (350,000 gas, FIPS 205 SPHINCS+) * Gas values are the marginal precompile verify-op costs measured on the * precompile fork (research/aip-draft-pqc-precompiles.md). * * 4. EIP-7825 GAS-CEILING HONESTY. AERE keeps Fusaka's per-transaction cap of * 2^24 = 16,777,216 gas. authorize() performs t independent precompile * verifications plus per-leg calldata (pubkey + signature) and bookkeeping, so * the committee's t is bounded. Using the CHEAPEST path (Falcon-512), the * per-leg cost is dominated by the 40,000-gas precompile verify plus ~1.6 KB * of pubkey+signature calldata and the distinct-member bookkeeping. The * practical (scheme, t) ceilings are documented in * docs/THRESHOLD-PQC-2026-07-12.md; Falcon-512 admits the largest committee * quorum, SLH-DSA-128s the smallest (its 7,856-byte signature makes calldata, * not the verify, the dominant per-leg cost). * * @dev PRECOMPILE INPUT ENVELOPE (identical to AerePQCAttestation, which is proven * bit-for-bit against the LIVE mainnet precompile via eth_call): * Falcon (1,2): signature = nonce(40) || esig (esig starts 0x29 / 0x2A) * ML-DSA-44 (3): signature = sig, exactly 2420 bytes * SLH-DSA-128s (4): signature = sig, exactly 7856 bytes * The contract supplies the 32-byte challenge itself, so no signer can sign a * message other than the one this contract binds. Precompile output is a raw * 32-byte word, 0x..01 valid else 0x..00; an empty/zero return (a stale/pre-fork * address) is treated as INVALID, so a mis-targeted call can never authorize. * * @dev REPLAY PROTECTION. Each committee has a strictly increasing `authNonce`. Every * member signs the SAME challenge, which binds * {domain, chainId, this contract, committeeId, authNonce, messageHash}. On a * successful authorization the nonce increments, so the exact same set of t * signatures can never be replayed (they verified only for the old nonce) and no * signature made for a different committee, message, contract, or chain verifies. */ contract AereThresholdPQCRegistry { // ========================================================================== // Scheme + precompile constants // ========================================================================== uint8 public constant SCHEME_FALCON512 = 1; uint8 public constant SCHEME_FALCON1024 = 2; uint8 public constant SCHEME_MLDSA44 = 3; uint8 public constant SCHEME_SLHDSA128S = 4; address public constant PRECOMPILE_FALCON512 = address(0x0AE1); address public constant PRECOMPILE_FALCON1024 = address(0x0AE2); address public constant PRECOMPILE_MLDSA44 = address(0x0AE3); address public constant PRECOMPILE_SLHDSA128S = address(0x0AE4); uint256 internal constant FALCON512_PK_LEN = 897; uint256 internal constant FALCON1024_PK_LEN = 1793; uint256 internal constant MLDSA44_PK_LEN = 1312; uint256 internal constant SLHDSA128S_PK_LEN = 32; uint8 internal constant FALCON512_PK_HEADER = 0x09; // 0x00 | logn=9 uint8 internal constant FALCON1024_PK_HEADER = 0x0A; // 0x00 | logn=10 uint256 internal constant MLDSA44_SIG_LEN = 2420; uint256 internal constant SLHDSA128S_SIG_LEN = 7856; uint256 internal constant FALCON_NONCE_LEN = 40; // Domain separator for the per-authorization challenge each member PQC-signs. bytes32 public constant AUTH_DOMAIN = keccak256("AereThresholdPQCRegistry.v1.authorize"); // ========================================================================== // Types // ========================================================================== struct Committee { bool exists; uint8 scheme; // one of SCHEME_* (whole committee shares one scheme) uint8 threshold; // t: distinct PQC signatures required to authorize uint8 size; // n: number of members uint64 authNonce; // strictly increasing; consumed on each successful authorize address registrant; // who registered (informational; not an authority) bytes32 membersHash; // keccak256(abi.encode(pubKeys)) integrity commitment } /// @notice One authorizing leg: the member's committee index and their PQC signature /// over the current authorization challenge, in the scheme's envelope. struct PqcSig { uint8 memberIndex; // 0..n-1, must be a distinct committee member bytes signature; // scheme envelope (see contract NatSpec) } /// @notice A recorded authorization, keyed by (committeeId, nonce). struct Authorization { bool exists; bytes32 messageHash; // the caller-supplied 32-byte payload bytes32 challenge; // the exact 32 bytes every leg PQC-signed uint8 quorum; // number of distinct PQC signers (== threshold at record time) uint64 blockNumber; } // ========================================================================== // Storage // ========================================================================== uint256 public committeeCount; mapping(uint256 => Committee) private _committees; // committeeId => ordered member PQC public keys (index is the memberIndex) mapping(uint256 => bytes[]) private _pubKeys; // keccak256(committeeId, nonce) => recorded authorization mapping(bytes32 => Authorization) private _auths; /// @notice Total number of authorizations recorded across all committees. uint256 public authorizationCount; // ========================================================================== // Events // ========================================================================== event CommitteeRegistered( uint256 indexed committeeId, address indexed registrant, uint8 indexed scheme, uint8 threshold, uint8 size ); event Authorized( uint256 indexed committeeId, uint64 indexed nonce, bytes32 indexed messageHash, bytes32 challenge, uint8 quorum, uint256 blockNumber ); // ========================================================================== // Errors // ========================================================================== error InvalidScheme(uint8 scheme); error InvalidPubKey(uint8 memberIndex); error DuplicatePubKey(uint8 memberIndex); error BadCommitteeParams(); error UnknownCommittee(uint256 committeeId); error MemberOutOfRange(uint8 memberIndex); error DuplicateMember(uint8 memberIndex); error LegVerificationFailed(uint8 memberIndex); error BelowThreshold(uint256 valid, uint8 required); // ========================================================================== // Registration // ========================================================================== /** * @notice Register a post-quantum t-of-n committee. * @param scheme one of 1=Falcon-512, 2=Falcon-1024, 3=ML-DSA-44, 4=SLH-DSA-128s. * All members use the same scheme. * @param threshold t, with 1 <= t <= n. A valid authorization needs >= t distinct * members to each PQC-sign the challenge. * @param pubKeys ordered member PQC public keys (n = length, 1 <= n <= 255). Each is * validated against the scheme's length (and Falcon header) so a * malformed key cannot be stored. The memberIndex is the array index. * @return committeeId the id of the new committee. * * @dev Permissionless: the registrant gains no privilege. Authority is ONLY the PQC * keys registered here; there is no admin, no owner override, no fund custody. * A `membersHash` commitment is stored so any relying party can pin the exact * key set a committee was created with. */ function registerCommittee(uint8 scheme, uint8 threshold, bytes[] calldata pubKeys) external returns (uint256 committeeId) { uint256 n = pubKeys.length; if (n < 1 || n > 255) revert BadCommitteeParams(); if (threshold < 1 || threshold > n) revert BadCommitteeParams(); // Committee keys MUST be distinct: authorization counts distinct signers by member INDEX, so a // repeated key would let one keyholder occupy multiple "distinct member" slots and reach the // threshold alone (same class as the AereThresholdAccount 2026-07-15 finding). Reject any dup // key here, at registration, by hashing once and comparing pairwise (n <= 255, one-time cost). bytes32[] memory keyHashes = new bytes32[](n); for (uint256 i = 0; i < n; i++) { _validatePubKey(scheme, pubKeys[i], uint8(i)); // also reverts InvalidScheme for a bad scheme bytes32 kh = keccak256(pubKeys[i]); for (uint256 j = 0; j < i; j++) { if (keyHashes[j] == kh) revert DuplicatePubKey(uint8(i)); } keyHashes[i] = kh; } committeeId = ++committeeCount; Committee storage c = _committees[committeeId]; c.exists = true; c.scheme = scheme; c.threshold = threshold; c.size = uint8(n); c.authNonce = 0; c.registrant = msg.sender; c.membersHash = keccak256(abi.encode(pubKeys)); bytes[] storage store = _pubKeys[committeeId]; for (uint256 i = 0; i < n; i++) { store.push(pubKeys[i]); } emit CommitteeRegistered(committeeId, msg.sender, scheme, threshold, uint8(n)); } function _validatePubKey(uint8 scheme, bytes calldata pubKey, uint8 memberIndex) internal pure { if (scheme == SCHEME_FALCON512) { if (pubKey.length != FALCON512_PK_LEN || uint8(pubKey[0]) != FALCON512_PK_HEADER) { revert InvalidPubKey(memberIndex); } } else if (scheme == SCHEME_FALCON1024) { if (pubKey.length != FALCON1024_PK_LEN || uint8(pubKey[0]) != FALCON1024_PK_HEADER) { revert InvalidPubKey(memberIndex); } } else if (scheme == SCHEME_MLDSA44) { if (pubKey.length != MLDSA44_PK_LEN) revert InvalidPubKey(memberIndex); } else if (scheme == SCHEME_SLHDSA128S) { if (pubKey.length != SLHDSA128S_PK_LEN) revert InvalidPubKey(memberIndex); } else { revert InvalidScheme(scheme); } } // ========================================================================== // Threshold authorization // ========================================================================== /** * @notice Authorize `messageHash` under a committee iff >= t DISTINCT committee * members each supply a valid PQC signature over the committee's current * authorization challenge, verified on-chain by the scheme's live precompile. * * challenge = keccak256(abi.encode( * AUTH_DOMAIN, block.chainid, address(this), committeeId, authNonce, messageHash)) * * Semantics (strict): EVERY provided leg must reference a distinct, in-range * member and must verify; a leg that is out of range reverts MemberOutOfRange, * a repeated member reverts DuplicateMember (so no member is ever double * counted), and a leg whose PQC signature does not verify reverts * LegVerificationFailed. After all legs are checked, the count of distinct * valid signers must be >= t or the call reverts BelowThreshold. On success * the authorization is recorded and the committee's authNonce increments, so * the same set of signatures can never be replayed. * * @param committeeId the target committee. * @param messageHash the 32-byte payload being authorized (caller binds its own * domain separation into this value for its use case). * @param sigs the authorizing legs (memberIndex + PQC signature envelope). * @return nonce the authNonce this authorization consumed. */ function authorize(uint256 committeeId, bytes32 messageHash, PqcSig[] calldata sigs) external returns (uint64 nonce) { Committee storage c = _committees[committeeId]; if (!c.exists) revert UnknownCommittee(committeeId); uint8 scheme = c.scheme; uint8 size = c.size; nonce = c.authNonce; bytes32 challenge = authChallenge(committeeId, nonce, messageHash); // Distinct-member bitmap over indices 0..size-1 (size <= 255 fits one word). uint256 seen = 0; uint256 valid = 0; bytes[] storage keys = _pubKeys[committeeId]; for (uint256 i = 0; i < sigs.length; i++) { uint8 idx = sigs[i].memberIndex; if (idx >= size) revert MemberOutOfRange(idx); uint256 bit = uint256(1) << idx; if (seen & bit != 0) revert DuplicateMember(idx); seen |= bit; if (!_verify(scheme, keys[idx], challenge, sigs[i].signature)) { revert LegVerificationFailed(idx); } valid += 1; } if (valid < c.threshold) revert BelowThreshold(valid, c.threshold); // Effects: consume the nonce and record before emitting. c.authNonce = nonce + 1; bytes32 id = authorizationId(committeeId, nonce); _auths[id] = Authorization({ exists: true, messageHash: messageHash, challenge: challenge, quorum: uint8(valid), blockNumber: uint64(block.number) }); authorizationCount += 1; emit Authorized(committeeId, nonce, messageHash, challenge, uint8(valid), block.number); } // ========================================================================== // Views / challenge helpers // ========================================================================== /** * @notice The exact 32-byte challenge every member must PQC-sign to authorize * `messageHash` under `committeeId` at a given `nonce`. Off-chain signers * query this (with the committee's current authNonce) to know what to sign. */ function authChallenge(uint256 committeeId, uint64 nonce, bytes32 messageHash) public view returns (bytes32) { return keccak256(abi.encode(AUTH_DOMAIN, block.chainid, address(this), committeeId, nonce, messageHash)); } /// @notice Deterministic id for the authorization recorded by `committeeId` at `nonce`. function authorizationId(uint256 committeeId, uint64 nonce) public pure returns (bytes32) { return keccak256(abi.encode(committeeId, nonce)); } /** * @notice Pure preview of whether a set of legs WOULD authorize at a given nonce, * without recording. Returns (would, validCount). Never reverts on a bad leg: * out-of-range and duplicate members are simply not counted, so this is a safe * off-chain quorum check. Note the on-chain authorize() is STRICT (it reverts * on any bad leg); this preview is intentionally lenient for UX. */ function previewAuthorize(uint256 committeeId, uint64 nonce, bytes32 messageHash, PqcSig[] calldata sigs) external view returns (bool would, uint256 validCount) { Committee storage c = _committees[committeeId]; if (!c.exists) return (false, 0); bytes32 challenge = authChallenge(committeeId, nonce, messageHash); bytes[] storage keys = _pubKeys[committeeId]; uint256 seen = 0; for (uint256 i = 0; i < sigs.length; i++) { uint8 idx = sigs[i].memberIndex; if (idx >= c.size) continue; uint256 bit = uint256(1) << idx; if (seen & bit != 0) continue; seen |= bit; if (_verify(c.scheme, keys[idx], challenge, sigs[i].signature)) validCount += 1; } would = validCount >= c.threshold; } function getCommittee(uint256 committeeId) external view returns ( bool exists, uint8 scheme, uint8 threshold, uint8 size, uint64 authNonce, address registrant, bytes32 membersHash ) { Committee storage c = _committees[committeeId]; return (c.exists, c.scheme, c.threshold, c.size, c.authNonce, c.registrant, c.membersHash); } function getMemberPubKey(uint256 committeeId, uint8 memberIndex) external view returns (bytes memory) { Committee storage c = _committees[committeeId]; if (!c.exists) revert UnknownCommittee(committeeId); if (memberIndex >= c.size) revert MemberOutOfRange(memberIndex); return _pubKeys[committeeId][memberIndex]; } function nonceOf(uint256 committeeId) external view returns (uint64) { Committee storage c = _committees[committeeId]; if (!c.exists) revert UnknownCommittee(committeeId); return c.authNonce; } function getAuthorization(uint256 committeeId, uint64 nonce) external view returns (bool exists, bytes32 messageHash, bytes32 challenge, uint8 quorum, uint64 blockNumber) { Authorization storage a = _auths[authorizationId(committeeId, nonce)]; return (a.exists, a.messageHash, a.challenge, a.quorum, a.blockNumber); } /// @notice True iff the authorization at (committeeId, nonce) exists and committed to `messageHash`. function isAuthorized(uint256 committeeId, uint64 nonce, bytes32 messageHash) external view returns (bool) { Authorization storage a = _auths[authorizationId(committeeId, nonce)]; return a.exists && a.messageHash == messageHash; } /// @notice The live precompile address for a scheme (reverts on an unknown scheme). function precompileFor(uint8 scheme) external pure returns (address) { if (scheme == SCHEME_FALCON512) return PRECOMPILE_FALCON512; if (scheme == SCHEME_FALCON1024) return PRECOMPILE_FALCON1024; if (scheme == SCHEME_MLDSA44) return PRECOMPILE_MLDSA44; if (scheme == SCHEME_SLHDSA128S) return PRECOMPILE_SLHDSA128S; revert InvalidScheme(scheme); } // ========================================================================== // Internal PQC verifier // ========================================================================== // // Identical envelope to AerePQCAttestation, which is proven bit-for-bit against the // LIVE mainnet precompile via eth_call (scripts/pqc/verify-live-precompile.js). We // inline it here so a committee authorization is one contract with no external call. function _verify(uint8 scheme, bytes memory pubKey, bytes32 message, bytes memory signature) internal view returns (bool) { (address precompile, bytes memory input, bool wellFormed) = _buildInput(scheme, pubKey, message, signature); if (!wellFormed) return false; // a wrong-length signature is invalid, not a revert (bool ok, bytes memory ret) = precompile.staticcall(input); return ok && ret.length >= 32 && ret[31] == 0x01; } /// @dev Assemble (precompile, input) for a scheme. `wellFormed` is false when the /// signature length cannot form a valid input (treated as an invalid leg, so a /// malformed signature never reverts authorize()); a well-formed-but-wrong /// signature yields an input the precompile rejects with 0. function _buildInput(uint8 scheme, bytes memory pubKey, bytes32 message, bytes memory signature) internal pure returns (address precompile, bytes memory input, bool wellFormed) { if (scheme == SCHEME_FALCON512 || scheme == SCHEME_FALCON1024) { if (signature.length <= FALCON_NONCE_LEN) return (address(0), "", false); uint256 sigLen = signature.length - FALCON_NONCE_LEN; // esig length if (sigLen > type(uint16).max) return (address(0), "", false); bytes memory nonce = _slice(signature, 0, FALCON_NONCE_LEN); bytes memory esig = _slice(signature, FALCON_NONCE_LEN, sigLen); // sm = sigLen(2, big-endian) || nonce(40) || message(32) || esig bytes memory sm = abi.encodePacked(uint16(sigLen), nonce, message, esig); input = abi.encodePacked(pubKey, sm); precompile = scheme == SCHEME_FALCON512 ? PRECOMPILE_FALCON512 : PRECOMPILE_FALCON1024; wellFormed = true; } else if (scheme == SCHEME_MLDSA44) { if (signature.length != MLDSA44_SIG_LEN) return (address(0), "", false); input = abi.encodePacked(pubKey, signature, message); // pk(1312) || sig(2420) || message(32) precompile = PRECOMPILE_MLDSA44; wellFormed = true; } else if (scheme == SCHEME_SLHDSA128S) { if (signature.length != SLHDSA128S_SIG_LEN) return (address(0), "", false); input = abi.encodePacked(pubKey, signature, message); // pk(32) || sig(7856) || message(32) precompile = PRECOMPILE_SLHDSA128S; wellFormed = true; } else { return (address(0), "", false); } } function _slice(bytes memory data, uint256 start, uint256 len) internal pure returns (bytes memory out) { out = new bytes(len); for (uint256 i = 0; i < len; i++) { out[i] = data[start + i]; } } }