aere-contracts/contracts/pqc/AerePQCMessageVerifier.sol
Aere Network a13a649b77
Some checks are pending
contracts-ci / Install (lockfile) → compile → full test suite (push) Waiting to run
contracts-ci / PQC known-answer tests (NIST vectors) (push) Waiting to run
contracts-ci / Coverage (scoped, with artifacts) (push) Waiting to run
Initial public release
Aere Network public source. Everything here can be checked against the live
chain (chain id 2800, https://rpc.aere.network).

Scope note, stated up front rather than buried: consensus on chain 2800 is
classical secp256k1 ECDSA QBFT. The post-quantum work in this repository is at
the signature, precompile, account and transport layers. Nothing here makes the
consensus post-quantum, and no document in it should be read as claiming so.
2026-07-20 01:02:37 +03:00

501 lines
25 KiB
Solidity

// 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);
}
}