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.
501 lines
25 KiB
Solidity
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);
|
|
}
|
|
}
|