aere-contracts/contracts/pqfinality/AerePQAttestationKeyRegistry.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

345 lines
17 KiB
Solidity

// SPDX-License-Identifier: MIT
pragma solidity 0.8.23;
import "@openzeppelin/contracts/access/Ownable.sol";
/**
* @title AerePQAttestationKeyRegistry, the post-quantum finality-attestation key
* registry for Aere Network validators (chain 2800).
*
* @notice This registry holds, for each Aere Network validator, a HASH-BASED
* attestation public key (Winternitz-XMSS / leanSig style, one-time or
* few-time hash-based signatures). A hash-based signature is post-quantum
* from hash collision and preimage resistance ALONE, so it survives a
* cryptographically-relevant quantum adversary that can forge the classical
* ECDSA (secp256k1) seals Aere consensus uses. This key is SEPARATE from a
* validator's ECDSA consensus key: it is the PQ FINALITY key, used only for
* an ADDITIVE post-quantum attestation over each finalized block.
*
* @notice The registry produces a deterministic `validatorSetRoot()`, a binding
* commitment to the enrolled validators and their current attestation keys.
* The off-chain aggregator reconstructs this same root, and
* AereFinalityCertificateVerifier binds every certificate to it, so a
* certificate proven against a stale or forged key set is rejected on-chain.
*
* @dev THE HONEST SCOPE BOUNDARY, read before trusting or describing anything.
*
* 1. THIS DOES NOT CHANGE AERE CONSENSUS. Blocks are STILL produced and
* finalized by Besu QBFT validators signing classical ECDSA (secp256k1)
* committed seals. This registry, and the certificate that reads it, are
* PURELY ADDITIVE: validators keep signing ECDSA for consensus and ALSO
* sign a hash-based attestation over each finalized block. Nothing here
* flips consensus, and PQ finality is not live. Never imply consensus is
* post-quantum.
*
* 2. NO ON-CHAIN HASH-BASED VERIFICATION HAPPENS HERE. This contract stores
* attestation public keys and commits to them in a root. The hash-based
* signatures themselves are verified OFF-CHAIN, inside a zkVM aggregation
* circuit (the [MEASURE] research component, see
* docs/AERE-PQ-FINALITY-CERTIFICATE.md), which recomputes this root from
* the witnessed keys. This registry is the on-chain anchor for that root.
*
* 3. APPEND-ONLY, FAIL-CLOSED, NO SILENT REWRITE. Every key ever registered
* is retained in `_keys` and reachable via the history views. A rotation
* never overwrites a key's material: it APPENDS a new record, marks the
* predecessor superseded (a metadata flag, not a deletion), and advances a
* monotonic per-validator `keyEpoch`. One-time and few-time hash-based keys
* MUST rotate as their signing capacity is used, so rotation is expected and
* cheap. A validator may only register its OWN key (msg.sender == validator);
* the owner cannot forge a validator's PQ key.
*
* 4. GOVERNED MEMBERSHIP, SELF-CUSTODIED KEYS. The owner (Aere Foundation,
* later the Timelock) enrolls the addresses that ARE validators, mirroring
* how QBFT consensus enrollment is curated off-chain via the Foundation
* Ledger and QBFT extraData. Enrollment defines WHO counts toward the
* finality quorum and fixes the root ordering. Each validator then registers
* its own attestation key. There is no fund custody and no key-forgery path.
*/
contract AerePQAttestationKeyRegistry is Ownable {
// ==========================================================================
// Hash-based scheme labels (post-quantum from hashing alone)
// ==========================================================================
//
// These name the hash-based signature family a validator's attestation key uses.
// They are hash-based, so their post-quantum security rests only on the collision
// and preimage resistance of the underlying hash, NOT on any lattice or discrete-log
// assumption. The scheme is recorded and committed into the root; the actual
// signature verification happens in the off-chain aggregation circuit.
uint8 public constant SCHEME_XMSS = 1; // RFC 8391 XMSS (stateful, few-time hash-based)
uint8 public constant SCHEME_WOTS = 2; // Winternitz OTS+ (one-time hash-based)
uint8 public constant SCHEME_LEANSIG = 3; // leanSig / lean hash-based (Ethereum Lean Consensus lineage)
uint8 public constant SCHEME_SLHDSA128S = 4; // SLH-DSA-128s (FIPS 205, stateless hash-based)
uint8 public constant SCHEME_XMSSMT = 5; // XMSS^MT (RFC 8391 multi-tree, more few-time capacity)
/// @dev Sane bounds for a hash-based attestation public key. Hash-based keys are
/// SMALL (an XMSS public key is a hash root plus parameters, tens of bytes),
/// so the generous upper bound only rejects obviously malformed input.
uint256 internal constant MIN_PUBKEY_LEN = 32;
uint256 internal constant MAX_PUBKEY_LEN = 2048;
/// @dev Sentinel for "this validator has no current key".
uint256 internal constant NO_KEY = type(uint256).max;
// ==========================================================================
// Types
// ==========================================================================
/// @notice An append-only attestation-key record. Never mutated except to flag it
/// superseded when the owning validator rotates to a successor.
struct KeyRecord {
address validator; // the validator that registered this key
uint8 scheme; // one of SCHEME_*
uint32 epoch; // this validator's key-epoch when the key was registered (1-based)
uint64 registeredBlock; // block the key was registered at
uint64 supersededBlock; // block a successor superseded it (0 while current)
bool superseded; // true once a successor key was registered
bytes pubKey; // the raw hash-based attestation public key
}
/// @notice Per-validator membership + current-key pointer.
struct Validator {
bool enrolled; // true once the owner enrolled this address
uint32 index; // position in `_validators` (fixes the root ordering)
uint32 keyEpoch; // 0 = no key yet; increments on each (re)registration
uint256 currentKeyId; // NO_KEY until the first key is registered
}
// ==========================================================================
// Storage
// ==========================================================================
address[] private _validators; // enrollment order == root ordering
mapping(address => Validator) private _validatorInfo;
KeyRecord[] private _keys; // append-only; keyId is the array index
mapping(address => uint256[]) private _validatorKeyHistory; // all keyIds per validator
// ==========================================================================
// Events
// ==========================================================================
event ValidatorEnrolled(address indexed validator, uint32 indexed index);
event AttestationKeyRegistered(
address indexed validator, uint256 indexed keyId, uint8 indexed scheme, uint32 epoch, uint256 supersededKeyId
);
// ==========================================================================
// Errors
// ==========================================================================
error AlreadyEnrolled(address validator);
error NotEnrolled(address validator);
error ZeroValidator();
error InvalidScheme(uint8 scheme);
error InvalidPubKey();
error UnknownKey(uint256 keyId);
// ==========================================================================
// Owner: set membership
// ==========================================================================
/**
* @notice Enroll `validator` into the Aere Network finality-attestation validator
* set. Owner only. Enrollment defines WHO counts toward the PQ finality
* quorum and fixes this validator's position in the root ordering. It does
* NOT register a key: the validator registers its own attestation key next.
* @dev Idempotent-guarded: re-enrolling a known validator reverts AlreadyEnrolled,
* so the set is append-only and the ordering never silently shifts.
*/
function enrollValidator(address validator) external onlyOwner {
if (validator == address(0)) revert ZeroValidator();
Validator storage v = _validatorInfo[validator];
if (v.enrolled) revert AlreadyEnrolled(validator);
uint32 index = uint32(_validators.length);
_validators.push(validator);
v.enrolled = true;
v.index = index;
v.keyEpoch = 0;
v.currentKeyId = NO_KEY;
emit ValidatorEnrolled(validator, index);
}
// ==========================================================================
// Validator: register / rotate own key
// ==========================================================================
/**
* @notice Register (or rotate to) the caller's hash-based attestation public key.
* Callable ONLY by an enrolled validator, for its OWN key. The first call
* sets key-epoch 1; each later call appends a fresh record, marks the
* previous key superseded, and advances the key-epoch, because one-time and
* few-time hash-based keys must rotate as their signing capacity is spent.
* Append-only: nothing is overwritten or deleted, and the full history is
* queryable.
* @param scheme one of SCHEME_* (a hash-based family).
* @param pubKey the raw hash-based attestation public key (length-checked only).
* @return keyId the identifier assigned to the new key.
*/
function registerAttestationKey(uint8 scheme, bytes calldata pubKey) external returns (uint256 keyId) {
Validator storage v = _validatorInfo[msg.sender];
if (!v.enrolled) revert NotEnrolled(msg.sender);
_validateKey(scheme, pubKey);
uint256 supersededKeyId = NO_KEY;
if (v.currentKeyId != NO_KEY) {
supersededKeyId = v.currentKeyId;
KeyRecord storage old = _keys[supersededKeyId];
old.superseded = true;
old.supersededBlock = uint64(block.number);
}
uint32 newEpoch = v.keyEpoch + 1;
keyId = _keys.length;
_keys.push(
KeyRecord({
validator: msg.sender,
scheme: scheme,
epoch: newEpoch,
registeredBlock: uint64(block.number),
supersededBlock: 0,
superseded: false,
pubKey: pubKey
})
);
v.keyEpoch = newEpoch;
v.currentKeyId = keyId;
_validatorKeyHistory[msg.sender].push(keyId);
emit AttestationKeyRegistered(msg.sender, keyId, scheme, newEpoch, supersededKeyId);
}
function _validateKey(uint8 scheme, bytes calldata pubKey) internal pure {
if (scheme < SCHEME_XMSS || scheme > SCHEME_XMSSMT) revert InvalidScheme(scheme);
if (pubKey.length < MIN_PUBKEY_LEN || pubKey.length > MAX_PUBKEY_LEN) revert InvalidPubKey();
}
// ==========================================================================
// Validator-set commitment (root)
// ==========================================================================
/**
* @notice The deterministic commitment to the current validator set and their
* current attestation keys. This is the value AereFinalityCertificateVerifier
* binds every certificate to, and the value the off-chain aggregation circuit
* MUST recompute from the witnessed keys.
*
* Definition (a flat, ordered keccak commitment):
* leaf_i = keccak256(abi.encode(validator_i, keyEpoch_i, keyHash_i))
* root = keccak256(abi.encode(leaf_0, leaf_1, ..., leaf_{N-1}))
* where keyHash_i = keccak256(currentPubKey_i), or bytes32(0) if validator_i
* has not registered a key yet, and the ordering is enrollment order.
*
* @dev Because the root commits to each validator's address AND its current
* key-epoch AND its current key material, ANY rotation (which bumps the
* epoch and changes the key hash) changes the root. A certificate proven
* against the pre-rotation root therefore stops matching automatically. A
* Merkle tree is a drop-in alternative if inclusion proofs are wanted; a
* flat commitment is sufficient because the circuit witnesses the whole set.
*/
function validatorSetRoot() public view returns (bytes32) {
uint256 n = _validators.length;
bytes32[] memory leaves = new bytes32[](n);
for (uint256 i = 0; i < n; i++) {
address a = _validators[i];
Validator storage v = _validatorInfo[a];
bytes32 keyHash = v.currentKeyId == NO_KEY ? bytes32(0) : keccak256(_keys[v.currentKeyId].pubKey);
leaves[i] = keccak256(abi.encode(a, v.keyEpoch, keyHash));
}
return keccak256(abi.encode(leaves));
}
// ==========================================================================
// Views
// ==========================================================================
/// @notice Number of enrolled validators (== N, the set size the quorum is over).
function validatorCount() external view returns (uint256) {
return _validators.length;
}
/// @notice The enrolled validator at a root-ordering position. Reverts if out of range.
function validatorAt(uint256 index) external view returns (address) {
if (index >= _validators.length) revert UnknownKey(index);
return _validators[index];
}
/// @notice The full enrolled validator set in root order.
function validators() external view returns (address[] memory) {
return _validators;
}
/// @notice True iff `validator` is enrolled in the set.
function isEnrolled(address validator) external view returns (bool) {
return _validatorInfo[validator].enrolled;
}
/// @notice The number of enrolled validators that currently have an attestation key.
function activeKeyCount() public view returns (uint256 count) {
uint256 n = _validators.length;
for (uint256 i = 0; i < n; i++) {
if (_validatorInfo[_validators[i]].currentKeyId != NO_KEY) count++;
}
}
/// @notice True iff EVERY enrolled validator has registered a current attestation key.
/// A certificate is only meaningful once the set is complete (a consumer that
/// gates on PQ finality SHOULD check this alongside the root).
function isSetComplete() external view returns (bool) {
uint256 n = _validators.length;
if (n == 0) return false;
return activeKeyCount() == n;
}
/// @notice The current attestation key of `validator`. Reverts NotEnrolled if the
/// address is not a validator; returns keyId == NO_KEY sentinel fields if the
/// validator has not registered a key yet.
function currentKey(address validator)
external
view
returns (uint256 keyId, uint8 scheme, uint32 epoch, bytes memory pubKey)
{
Validator storage v = _validatorInfo[validator];
if (!v.enrolled) revert NotEnrolled(validator);
keyId = v.currentKeyId;
if (keyId == NO_KEY) return (NO_KEY, 0, 0, "");
KeyRecord storage k = _keys[keyId];
return (keyId, k.scheme, k.epoch, k.pubKey);
}
/// @notice The current key-epoch of `validator` (0 = no key registered yet).
function keyEpochOf(address validator) external view returns (uint32) {
return _validatorInfo[validator].keyEpoch;
}
/// @notice Total number of key records ever registered (keyIds run 0..keyCount-1).
function keyCount() external view returns (uint256) {
return _keys.length;
}
/// @notice Read an append-only key record by id. Reverts UnknownKey on a bad id.
function getKey(uint256 keyId)
external
view
returns (
address validator,
uint8 scheme,
uint32 epoch,
uint64 registeredBlock,
uint64 supersededBlock,
bool superseded,
bytes memory pubKey
)
{
if (keyId >= _keys.length) revert UnknownKey(keyId);
KeyRecord storage k = _keys[keyId];
return (k.validator, k.scheme, k.epoch, k.registeredBlock, k.supersededBlock, k.superseded, k.pubKey);
}
/// @notice All keyIds ever registered by `validator`, oldest first (append-only history).
function keyHistoryOf(address validator) external view returns (uint256[] memory) {
return _validatorKeyHistory[validator];
}
}