// SPDX-License-Identifier: MIT pragma solidity 0.8.23; /// @notice Minimal surface of AerePQCMessageVerifier a consumer needs to gate an /// action on a valid post-quantum signature by a registered signer. interface IAerePQCMessageVerifier { function verifyBySigner(uint256 signerId, bytes32 domainTag, bytes calldata payload, bytes calldata signature) external view returns (bool); function messageDigest(bytes32 domainTag, bytes calldata payload) external view returns (bytes32); function signerMessageDigest(uint256 signerId, bytes32 domainTag, bytes calldata payload) external view returns (bytes32); } /** * @title AerePQCMessageVerifier, on-chain verifier of post-quantum-signed messages for * cross-chain bridging and oracle attestations (AERE chain 2800) * * @notice A permissionless, non-custodial primitive that lets a bridge relayer, oracle, * or off-chain attestor PUBLISH a NIST post-quantum public key and then have its * messages (cross-chain packets, price/NAV attestations, proofs) checked, in full, * on-chain, against that key. Verification rests on AERE's LIVE native PQC * precompiles, so this contract is deployable TODAY on mainnet 2800. No funds are * ever held. * * What it adds over a raw precompile call: * 1. a SIGNER REGISTRY with owner-gated ROTATION and REVOCATION, so a bridge/ * oracle keeps a stable `signerId` while it rolls its PQC key; * 2. a DOMAIN-SEPARATED message format binding chainId + this deployment + a * domain tag + the payload, so a signature can never be replayed across * chains, contracts, domains, or (for signer-bound checks) key epochs; * 3. `verifyMessage` for stateless verification of any NIST PQC signature over * an arbitrary-length message, plus signer-bound helpers a consumer gates on. * * HONEST SCOPE. Application-layer PQC signature verification. This does NOT change * AERE consensus, which remains classical ECDSA QBFT (Foundation validators). What * is new and live is the set of native precompiles this contract calls, activated * on mainnet 2800 at block 9,189,161, each wrapping the audited Bouncy Castle NIST * verifiers. This contract makes those precompiles USABLE for the bridging/oracle * message-authentication pattern. * * @dev LIVE PRECOMPILES USED (mainnet 2800, all activated at block 9,189,161): * scheme 1 = Falcon-512 at 0x...0AE1 * scheme 2 = Falcon-1024 at 0x...0AE2 * scheme 3 = ML-DSA-44 at 0x...0AE3 (FIPS 204, internal interface) * scheme 4 = SLH-DSA-128s at 0x...0AE4 (FIPS 205 SPHINCS+, internal interface) * * The precompiles ML-KEM-768 (0x...0AE6) and Falcon HashToPoint (0x...0AE7) are * TESTNET-ONLY and NOT live on mainnet; this contract deliberately does not use * them, so it stays deployable on mainnet today. (ML-KEM is a KEM, not a signature * scheme, so it would not belong here regardless.) * * @dev PRECOMPILE INPUT ENCODING (byte-identical to AerePQCAttestation, separately * proven against the live mainnet precompiles via eth_call in scripts/pqc): * * Falcon-512 / Falcon-1024 (0x0AE1 / 0x0AE2): * input = pk || sm * pk = (0x00+logn) || packed_h (897 bytes logn=9, 1793 bytes logn=10) * sm = sigLen(2, big-endian) || nonce(40) || message || esig * esig = (0x20+logn) || compressedSig, sigLen == esig.length * * ML-DSA-44 (0x0AE3): input = pk(1312) || sig(2420) || message * SLH-DSA-128s (0x0AE4): input = pk(32) || sig(7856) || message * * Output for every scheme: a 32-byte word, 0x..01 valid else 0x..00. An empty/zero * return is treated as INVALID (this is the pre-fork behaviour of an address with * no precompile, so a stale caller can never accept a signature). * * @dev SIGNATURE ENVELOPE handed to every verify* function: * 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 * Unlike AerePQCAttestation, `message` here is arbitrary-length: a bridge packet or * oracle report can be signed directly, and the domain-separated helpers commit a * 32-byte digest. */ contract AerePQCMessageVerifier { // ----- scheme identifiers (match AerePQCAttestation / AerePQCKeyRegistry) ----- uint8 public constant SCHEME_FALCON512 = 1; uint8 public constant SCHEME_FALCON1024 = 2; uint8 public constant SCHEME_MLDSA44 = 3; uint8 public constant SCHEME_SLHDSA128S = 4; // ----- live precompile addresses on chain 2800 ----- 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); // ----- public key sizes / headers ----- 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 // ----- signature sizes (fixed-length schemes) ----- 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 message digest ----- /// @notice Root domain tag mixed into every digest, keeping AERE PQC message digests /// disjoint from AerePQCAttestation / AerePQCKeyRegistry / the recovery module, /// so a signature made for one primitive can never be replayed on another. bytes32 public constant MESSAGE_DOMAIN = keccak256("AerePQCMessageVerifier.v1.message"); // ----- signer lifecycle ----- // NONE=0 (never registered), ACTIVE=1, REVOKED=2 (terminal). A rotation keeps the same // signerId ACTIVE and advances its epoch; a revoked signer can never verify again. uint8 public constant STATUS_NONE = 0; uint8 public constant STATUS_ACTIVE = 1; uint8 public constant STATUS_REVOKED = 2; struct Signer { address owner; // the address that may rotate/revoke this signer uint8 scheme; // current scheme (one of SCHEME_*) uint8 status; // one of STATUS_* uint32 epoch; // advances on every rotation; bound into signer digests bytes pubKey; // current raw NIST public key for the scheme } Signer[] private _signers; // signerId is the index into this array // ----- events ----- event SignerRegistered(uint256 indexed signerId, address indexed owner, uint8 indexed scheme, uint256 pubKeyLen); event SignerRotated( uint256 indexed signerId, address indexed owner, uint8 newScheme, uint32 newEpoch, uint256 pubKeyLen ); event SignerRevoked(uint256 indexed signerId, address indexed owner); event SignerOwnerTransferred(uint256 indexed signerId, address indexed oldOwner, address indexed newOwner); error InvalidScheme(uint8 scheme); error InvalidPubKey(); error UnknownSigner(uint256 signerId); error NotSignerOwner(uint256 signerId); error SignerNotActive(uint256 signerId); error ZeroAddress(); // ========================================================================== // Signer registry // ========================================================================== /** * @notice Register a PQC signer public key and bind it to msg.sender. The registrant * becomes the signer OWNER (the only party that can later rotate or revoke it). * Multiple signers per address are allowed; each call returns a fresh signerId. * The key length (and, for Falcon, the header byte) is validated against the * scheme so a malformed key cannot be stored. * @param scheme one of 1=Falcon-512, 2=Falcon-1024, 3=ML-DSA-44, 4=SLH-DSA-128s. * @param pubKey the raw NIST public key for the scheme (see contract NatSpec). * @return signerId the identifier assigned to this signer. */ function registerSigner(uint8 scheme, bytes calldata pubKey) external returns (uint256 signerId) { _validatePubKey(scheme, pubKey); signerId = _signers.length; _signers.push(Signer({owner: msg.sender, scheme: scheme, status: STATUS_ACTIVE, epoch: 0, pubKey: pubKey})); emit SignerRegistered(signerId, msg.sender, scheme, pubKey.length); } /** * @notice Rotate a signer's key in place. Only the signer owner may call, and only while * the signer is ACTIVE. The signerId is preserved (consumers keep pointing at the * same signer), the new key becomes current, and `epoch` advances by one. Because * signer-bound digests include the epoch, any message signed for a prior epoch is * invalid after rotation even if the same key material is re-used. The successor * may use any of the four schemes. * @param signerId the signer to rotate. * @param newScheme the scheme of the successor key. * @param newPubKey the raw NIST public key for the successor. */ function rotateSigner(uint256 signerId, uint8 newScheme, bytes calldata newPubKey) external { Signer storage s = _requireSigner(signerId); if (s.owner != msg.sender) revert NotSignerOwner(signerId); if (s.status != STATUS_ACTIVE) revert SignerNotActive(signerId); _validatePubKey(newScheme, newPubKey); s.scheme = newScheme; s.pubKey = newPubKey; s.epoch += 1; emit SignerRotated(signerId, msg.sender, newScheme, s.epoch, newPubKey.length); } /** * @notice Permanently revoke a signer you own. Terminal: a revoked signer can never * verify again (verifyBySigner returns false) and cannot be reactivated. */ function revokeSigner(uint256 signerId) external { Signer storage s = _requireSigner(signerId); if (s.owner != msg.sender) revert NotSignerOwner(signerId); if (s.status != STATUS_ACTIVE) revert SignerNotActive(signerId); s.status = STATUS_REVOKED; emit SignerRevoked(signerId, msg.sender); } /** * @notice Hand a signer's control to a new owner (e.g. rotating operational custody of a * bridge relayer). Only the current owner may call, only while ACTIVE. */ function transferSignerOwner(uint256 signerId, address newOwner) external { if (newOwner == address(0)) revert ZeroAddress(); Signer storage s = _requireSigner(signerId); if (s.owner != msg.sender) revert NotSignerOwner(signerId); if (s.status != STATUS_ACTIVE) revert SignerNotActive(signerId); address old = s.owner; s.owner = newOwner; emit SignerOwnerTransferred(signerId, old, newOwner); } function _validatePubKey(uint8 scheme, bytes calldata pubKey) internal pure { if (scheme == SCHEME_FALCON512) { if (pubKey.length != FALCON512_PK_LEN || uint8(pubKey[0]) != FALCON512_PK_HEADER) revert InvalidPubKey(); } else if (scheme == SCHEME_FALCON1024) { if (pubKey.length != FALCON1024_PK_LEN || uint8(pubKey[0]) != FALCON1024_PK_HEADER) revert InvalidPubKey(); } else if (scheme == SCHEME_MLDSA44) { if (pubKey.length != MLDSA44_PK_LEN) revert InvalidPubKey(); } else if (scheme == SCHEME_SLHDSA128S) { if (pubKey.length != SLHDSA128S_PK_LEN) revert InvalidPubKey(); } else { revert InvalidScheme(scheme); } } function _requireSigner(uint256 signerId) internal view returns (Signer storage s) { if (signerId >= _signers.length) revert UnknownSigner(signerId); s = _signers[signerId]; } // ========================================================================== // Domain-separated message format // ========================================================================== /** * @notice The 32-byte digest a signature must commit to for a message under `domainTag`. * Binds MESSAGE_DOMAIN + chainId + this deployment + the domain tag + the payload, * so the SAME payload signed for one (chain, contract, domain) is a DIFFERENT * digest on any other, and a signature can never be replayed across them. * This form is NOT bound to a signer, for stateless multi-party checks. * @param domainTag an application-chosen tag separating message classes (e.g. a bridge * route id or oracle feed id). * @param payload the message bytes being attested (arbitrary length). */ function messageDigest(bytes32 domainTag, bytes calldata payload) public view returns (bytes32) { return keccak256(abi.encode(MESSAGE_DOMAIN, block.chainid, address(this), domainTag, payload)); } /** * @notice The 32-byte digest for a message bound to a specific signer AND its current * rotation epoch. Additionally binds `signerId` and `epoch`, so a signature is * scoped to one signer and one key generation: after a rotation (epoch advances) * or a re-registration under a new signerId, an old signature no longer verifies. * Off-chain signers query this (or reconstruct it) to know exactly what to sign. */ function signerMessageDigest(uint256 signerId, bytes32 domainTag, bytes calldata payload) public view returns (bytes32) { Signer storage s = _requireSigner(signerId); return keccak256( abi.encode(MESSAGE_DOMAIN, block.chainid, address(this), signerId, s.epoch, domainTag, payload) ); } // ========================================================================== // Verification (views) // ========================================================================== /** * @notice Stateless PQC verification of `signature` by `pubKey` over an arbitrary-length * `message`, via the live precompile for `scheme`. No registry or binding: the * message is used exactly as given, so anyone can check any NIST PQC signature via * eth_call for free (e.g. against the official KAT vectors). Fail-closed: returns * false, never reverts, on a bad scheme/length or a rejecting precompile. * @return true iff the live precompile accepts the signature. */ function verifyMessage(uint8 scheme, bytes calldata pubKey, bytes calldata message, bytes calldata signature) external view returns (bool) { return _verify(scheme, pubKey, message, signature); } /** * @notice Domain-separated verification against a caller-supplied public key: verifies * `signature` (by `pubKey`, `scheme`) over messageDigest(domainTag, payload). * Stateless (no signer registry). Fail-closed. */ function verifyDomainMessage( uint8 scheme, bytes calldata pubKey, bytes32 domainTag, bytes calldata payload, bytes calldata signature ) external view returns (bool) { bytes32 digest = messageDigest(domainTag, payload); return _verify(scheme, pubKey, abi.encodePacked(digest), signature); } /** * @notice Signer-bound, domain-separated verification: verifies `signature` under a * REGISTERED signer's CURRENT key and scheme, over signerMessageDigest(signerId, * domainTag, payload). This is the check a consumer gates an action on. Fail-closed: * returns false for an unknown or REVOKED signer, or a rejecting precompile. Because * the digest binds the signer's epoch, a signature made before a rotation is rejected. */ function verifyBySigner(uint256 signerId, bytes32 domainTag, bytes calldata payload, bytes calldata signature) external view returns (bool) { if (signerId >= _signers.length) return false; Signer storage s = _signers[signerId]; if (s.status != STATUS_ACTIVE) return false; bytes32 digest = keccak256(abi.encode(MESSAGE_DOMAIN, block.chainid, address(this), signerId, s.epoch, domainTag, payload)); return _verify(s.scheme, s.pubKey, abi.encodePacked(digest), signature); } // ========================================================================== // Internal verifier // ========================================================================== /** * @dev Build the exact precompile input for `scheme` with `message` as the signed message, * staticcall the live precompile, and return true iff it answers 0x..01. Fail-closed: * any length/scheme that cannot form a valid input, or an empty/short/zero return, * yields false (never reverts), mirroring AerePQCKeyRegistry._verify. */ function _verify(uint8 scheme, bytes memory pubKey, bytes memory message, bytes memory signature) internal view returns (bool) { (bool built, address precompile, bytes memory input) = _tryBuildInput(scheme, pubKey, message, signature); if (!built) return false; (bool ok, bytes memory ret) = precompile.staticcall(input); return ok && ret.length >= 32 && ret[31] == 0x01; } /// @dev Assemble (precompile, input) for a scheme (byte-identical layout to /// AerePQCAttestation / AerePQCKeyRegistry). Returns built=false on any length/scheme /// that cannot form a valid input, so verification fails closed rather than reverting. /// `message` is arbitrary length (a bridge packet, oracle report, or 32-byte digest). function _tryBuildInput(uint8 scheme, bytes memory pubKey, bytes memory message, bytes memory signature) internal pure returns (bool built, address precompile, bytes memory input) { if (scheme == SCHEME_FALCON512 || scheme == SCHEME_FALCON1024) { // signature = nonce(40) || esig if (signature.length <= FALCON_NONCE_LEN) return (false, address(0), ""); uint256 sigLen = signature.length - FALCON_NONCE_LEN; // esig length if (sigLen > type(uint16).max) return (false, address(0), ""); 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 || esig bytes memory sm = abi.encodePacked(uint16(sigLen), nonce, message, esig); input = abi.encodePacked(pubKey, sm); precompile = scheme == SCHEME_FALCON512 ? PRECOMPILE_FALCON512 : PRECOMPILE_FALCON1024; } else if (scheme == SCHEME_MLDSA44) { if (signature.length != MLDSA44_SIG_LEN) return (false, address(0), ""); input = abi.encodePacked(pubKey, signature, message); // pk(1312) || sig(2420) || message precompile = PRECOMPILE_MLDSA44; } else if (scheme == SCHEME_SLHDSA128S) { if (signature.length != SLHDSA128S_SIG_LEN) return (false, address(0), ""); input = abi.encodePacked(pubKey, signature, message); // pk(32) || sig(7856) || message precompile = PRECOMPILE_SLHDSA128S; } else { return (false, address(0), ""); } built = true; } 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]; } } // ========================================================================== // Views // ========================================================================== /// @notice Number of registered signers (signerIds run 0..signerCount-1). function signerCount() external view returns (uint256) { return _signers.length; } /// @notice Read a registered signer. Reverts on an unknown signerId. function getSigner(uint256 signerId) external view returns (address owner, uint8 scheme, uint8 status, uint32 epoch, bytes memory pubKey) { Signer storage s = _requireSigner(signerId); return (s.owner, s.scheme, s.status, s.epoch, s.pubKey); } /// @notice Lifecycle status of a signer (STATUS_NONE for a never-registered id). function statusOf(uint256 signerId) external view returns (uint8) { if (signerId >= _signers.length) return STATUS_NONE; return _signers[signerId].status; } /// @notice True iff the signer exists and is ACTIVE (not revoked). function isActiveSigner(uint256 signerId) external view returns (bool) { if (signerId >= _signers.length) return false; return _signers[signerId].status == STATUS_ACTIVE; } /// @notice The current rotation epoch of a signer (0 at registration). function epochOf(uint256 signerId) external view returns (uint32) { return _requireSigner(signerId).epoch; } /// @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); } } /** * @title AerePQCGatedAction, minimal example consumer that gates an action on a valid PQC * signature by a registered signer * * @notice Reference for how a bridge receiver or oracle sink authenticates messages with * AerePQCMessageVerifier. An action executes iff a registered signer produced a valid * post-quantum signature over the (domain-separated) payload, and each logical action * executes at most once. The payload is opaque here (a bridge packet, an oracle report, * a proof commitment); a real consumer would decode and act on it. * * @dev Replay protection: a logical action is identified by (signerId, payload) under this * consumer's ACTION_DOMAIN, and marked consumed on first success. Combined with the * verifier's chain/contract/domain/epoch binding, a signature accepted here cannot be * replayed on another chain, another consumer, another domain, or a rotated key, and the * same action cannot be executed twice. Authentication is the PQC signature alone, so any * relayer may submit it (msg.sender is not trusted), which is the point: the authority is * post-quantum, not the secp256k1 tx sender. */ contract AerePQCGatedAction { IAerePQCMessageVerifier public immutable verifier; /// @notice Domain tag for this consumer's messages (disjoint from other consumers/domains). bytes32 public constant ACTION_DOMAIN = keccak256("AerePQCGatedAction.v1.action"); /// @notice actionId => already executed. mapping(bytes32 => bool) public consumed; /// @notice Total actions executed. uint256 public actionCount; event ActionExecuted(uint256 indexed signerId, bytes32 indexed actionId, bytes payload); error BadVerifier(); error NotAttested(); error AlreadyConsumed(bytes32 actionId); constructor(IAerePQCMessageVerifier verifier_) { if (address(verifier_) == address(0)) revert BadVerifier(); verifier = verifier_; } /// @notice The identifier under which (signerId, payload) is single-use in this consumer. function actionId(uint256 signerId, bytes calldata payload) public pure returns (bytes32) { return keccak256(abi.encode(signerId, payload)); } /** * @notice Execute the gated action iff `signature` is a valid PQC signature by `signerId`'s * current key over this consumer's domain-separated `payload`. Permissionless (any * relayer may submit). Reverts (nothing recorded) if the signature does not verify or * the action was already consumed. */ function executeIfAttested(uint256 signerId, bytes calldata payload, bytes calldata signature) external { bytes32 id = keccak256(abi.encode(signerId, payload)); if (consumed[id]) revert AlreadyConsumed(id); if (!verifier.verifyBySigner(signerId, ACTION_DOMAIN, payload, signature)) revert NotAttested(); consumed[id] = true; actionCount += 1; emit ActionExecuted(signerId, id, payload); } }