Some checks failed
contracts-ci / Install (lockfile) → compile → full test suite (push) Has been cancelled
contracts-ci / Ethereum interop (EIP-2537 BLS, prague hardfork) (push) Has been cancelled
contracts-ci / PQC known-answer tests (NIST vectors) (push) Has been cancelled
contracts-ci / Coverage (scoped, with artifacts) (push) Has been cancelled
The published line and the local line had no common ancestor: the public one carried the redaction pass, the local one carried three weeks of corrections that never shipped. This commit ports the local work onto the public line, keeps every public redaction, and extends the same discretion to seven client mentions that were still named in published comments. Carried: LICENSE year and LICENSING.md; the measured burn figures replacing the deflation claim (the vault holds ~0.137 AERE of 2.8 billion, and burn is a share of validator coinbase revenue, which is zero today); 'audited' removed from next to Bouncy Castle; citation paths rewritten to published form with CITATIONS-UNRESOLVED.md remeasured 2026-08-11; VERIFY-POLICY.md; slashing and ownership comments brought down to what the code does; the AerePyth repair; the shutter test helper the tests cite; runnable package.json entries; the CI file split into a GitHub/Gitea twin pair with a real measured test-run status; and the .gitignore hardening written after a compiled artifact leaked a local path in a sibling repository. A false '2-of-3 multisig' description of the owner account is corrected to what the chain measures: an externally owned account. The self-audit findings catalog stays unpublished pending an explicit decision.
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 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);
|
|
}
|
|
}
|