aere-contracts/contracts/pqc/AerePQCKeyRegistry.sol
Aere Network acac2f00a6
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 unpublished line of work joins the sanitized public line
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.
2026-08-15 13:59:30 +03:00

412 lines
19 KiB
Solidity

// SPDX-License-Identifier: MIT
pragma solidity 0.8.23;
/**
* @title AerePQCKeyRegistry, permissionless post-quantum public-key registry with
* on-chain proof-of-possession (AERE chain 2800)
*
* @notice The anchor a wallet, agent, or app uses to PUBLISH its post-quantum public
* keys and prove, on-chain, that it actually holds the matching private key.
* Anyone can bind one or more NIST PQC public keys (Falcon-512, Falcon-1024,
* ML-DSA-44, SLH-DSA-128s) to their identity (an address). Registration is
* gated by a PROOF-OF-POSSESSION: the registrant must supply a valid PQC
* signature, verified in full on-chain by AERE's live native precompile, over
* a challenge this contract binds to (chain, contract, owner, scheme, key,
* per-identity nonce). A party that does not hold the private key cannot
* produce that signature, so a key can never be registered under an identity
* that does not control it, and an observed proof cannot be replayed to claim
* the key under a different address (the owner is inside the challenge).
*
* Keys can be ROTATED (register a successor, proving possession of the NEW key,
* which retires the old one) and REVOKED (terminal), by the identity owner only.
*
* HONEST SCOPE. Application-layer PQC verification. This does NOT change AERE
* consensus: blocks are still produced and signed by Besu QBFT validators with
* classical ECDSA (secp256k1). What is new and live is the set of native
* precompiles this contract calls (mainnet 2800, activation block 9,189,161),
* each wrapping the Bouncy Castle NIST verifiers. No funds are ever held.
*
* @dev The precompile wire format is IDENTICAL to AerePQCAttestation (proven against
* the live mainnet precompiles via eth_call). See that contract's NatSpec and
* scripts/pqc for the byte layout; the encoding is reproduced verbatim below so
* a consumer can verify one file. Signature envelope handed to this contract:
* 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
* This contract supplies the 32-byte challenge/message itself, so a signer can
* never bind a proof to anything other than what this contract commits to.
*/
contract AerePQCKeyRegistry {
// ----- scheme identifiers (match AerePQCAttestation) -----
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 proof-of-possession challenge -----
bytes32 public constant POP_DOMAIN = keccak256("AerePQCKeyRegistry.v1.pop");
// ----- key lifecycle -----
// NONE=0 (never registered), ACTIVE=1, ROTATED=2 (superseded by a successor),
// REVOKED=3 (terminal). Only ACTIVE keys are "current"; ROTATED keys still verify
// cryptographically (historical), REVOKED keys never verify.
uint8 public constant STATUS_NONE = 0;
uint8 public constant STATUS_ACTIVE = 1;
uint8 public constant STATUS_ROTATED = 2;
uint8 public constant STATUS_REVOKED = 3;
uint256 internal constant NO_KEY = type(uint256).max; // sentinel: "no linked key"
struct KeyRecord {
address owner; // identity that proved possession and controls the key
uint8 scheme; // one of SCHEME_*
uint8 status; // one of STATUS_*
uint64 registeredBlock; // block the key was registered at
uint256 rotatedFromId; // predecessor keyId (NO_KEY if registered fresh)
uint256 rotatedToId; // successor keyId (NO_KEY until rotated away)
bytes pubKey; // the raw NIST public key for the scheme
}
KeyRecord[] private _keys; // keyId is the index into this array
// Per-identity monotonic nonce making every proof-of-possession challenge unique
// (prevents replay across registrations by the same identity).
mapping(address => uint64) public identityNonce;
// All keyIds ever registered by an identity (append-only; includes retired/revoked).
mapping(address => uint256[]) private _identityKeys;
// ----- events -----
event KeyRegistered(uint256 indexed keyId, address indexed owner, uint8 indexed scheme, uint64 popNonce);
event KeyRotated(uint256 indexed oldKeyId, uint256 indexed newKeyId, address indexed owner);
event KeyRevoked(uint256 indexed keyId, address indexed owner);
error InvalidScheme(uint8 scheme);
error InvalidPubKey();
error UnknownKey(uint256 keyId);
error NotKeyOwner(uint256 keyId);
error KeyNotActive(uint256 keyId);
error KeyAlreadyRevoked(uint256 keyId);
error ProofOfPossessionFailed();
// ==========================================================================
// Registration
// ==========================================================================
/**
* @notice Register a PQC public key under msg.sender, proving possession on-chain.
* The proof is a valid PQC `signature` by `pubKey` over the current
* proof-of-possession challenge for (msg.sender, scheme, pubKey). On
* success the key is stored ACTIVE and the identity nonce advances so the
* same proof cannot be replayed.
* @param scheme 1=Falcon-512, 2=Falcon-1024, 3=ML-DSA-44, 4=SLH-DSA-128s.
* @param pubKey the raw NIST public key (length/header validated per scheme).
* @param signature the PQC proof-of-possession envelope (see contract NatSpec).
* @return keyId the identifier assigned to the new key.
*/
function registerKey(uint8 scheme, bytes calldata pubKey, bytes calldata signature)
external
returns (uint256 keyId)
{
keyId = _registerWithPoP(msg.sender, scheme, pubKey, signature, NO_KEY);
}
/**
* @notice Rotate `oldKeyId` to a fresh successor key, proving possession of the NEW
* key. Only the identity that owns `oldKeyId` may rotate it, and only while
* `oldKeyId` is ACTIVE. The old key becomes ROTATED (it still verifies as a
* historical key but is no longer current); the new key is ACTIVE and linked
* to its predecessor. The new key may use any of the four schemes.
* @return newKeyId the identifier assigned to the successor key.
*/
function rotateKey(uint256 oldKeyId, uint8 newScheme, bytes calldata newPubKey, bytes calldata signature)
external
returns (uint256 newKeyId)
{
if (oldKeyId >= _keys.length) revert UnknownKey(oldKeyId);
KeyRecord storage old = _keys[oldKeyId];
if (old.owner != msg.sender) revert NotKeyOwner(oldKeyId);
if (old.status != STATUS_ACTIVE) revert KeyNotActive(oldKeyId);
newKeyId = _registerWithPoP(msg.sender, newScheme, newPubKey, signature, oldKeyId);
old.status = STATUS_ROTATED;
old.rotatedToId = newKeyId;
emit KeyRotated(oldKeyId, newKeyId, msg.sender);
}
/**
* @notice Permanently revoke a key you own. Terminal: a revoked key can never
* verify again and cannot be reactivated. Callable while ACTIVE or ROTATED.
*/
function revokeKey(uint256 keyId) external {
if (keyId >= _keys.length) revert UnknownKey(keyId);
KeyRecord storage k = _keys[keyId];
if (k.owner != msg.sender) revert NotKeyOwner(keyId);
if (k.status == STATUS_REVOKED) revert KeyAlreadyRevoked(keyId);
k.status = STATUS_REVOKED;
emit KeyRevoked(keyId, msg.sender);
}
// ==========================================================================
// Internal registration core
// ==========================================================================
function _registerWithPoP(
address owner,
uint8 scheme,
bytes calldata pubKey,
bytes calldata signature,
uint256 rotatedFromId
) internal returns (uint256 keyId) {
_validatePubKey(scheme, pubKey);
uint64 nonce = identityNonce[owner];
bytes32 challenge = _popChallenge(owner, scheme, keccak256(pubKey), nonce);
// Full on-chain PQC proof-of-possession over the contract-bound challenge.
if (!_verify(scheme, pubKey, challenge, signature)) revert ProofOfPossessionFailed();
// Effects: advance the identity nonce BEFORE storing so any reentrant view
// sees a consumed proof, and the same signature can never be replayed.
identityNonce[owner] = nonce + 1;
keyId = _keys.length;
_keys.push(
KeyRecord({
owner: owner,
scheme: scheme,
status: STATUS_ACTIVE,
registeredBlock: uint64(block.number),
rotatedFromId: rotatedFromId,
rotatedToId: NO_KEY,
pubKey: pubKey
})
);
_identityKeys[owner].push(keyId);
emit KeyRegistered(keyId, owner, scheme, nonce);
}
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);
}
}
// ==========================================================================
// Challenges (views)
// ==========================================================================
/**
* @notice The proof-of-possession challenge an identity must sign to register/rotate
* a key at a given nonce. Off-chain signers query this to know what to sign.
*/
function popChallenge(address owner, uint8 scheme, bytes calldata pubKey, uint64 nonce)
external
view
returns (bytes32)
{
return _popChallenge(owner, scheme, keccak256(pubKey), nonce);
}
/// @notice The challenge for `owner` to register `pubKey` right now (current nonce).
function currentPopChallenge(address owner, uint8 scheme, bytes calldata pubKey) external view returns (bytes32) {
return _popChallenge(owner, scheme, keccak256(pubKey), identityNonce[owner]);
}
function _popChallenge(address owner, uint8 scheme, bytes32 pubKeyHash, uint64 nonce)
internal
view
returns (bytes32)
{
return keccak256(abi.encode(POP_DOMAIN, block.chainid, address(this), owner, scheme, pubKeyHash, nonce));
}
// ==========================================================================
// Verification helpers (views)
// ==========================================================================
/**
* @notice Verify a PQC signature over a raw 32-byte message with a REGISTERED key,
* via the live precompile. Fail-closed: returns false (never reverts) for an
* unknown key, a REVOKED key, or a malformed signature. ACTIVE and ROTATED
* keys verify (a ROTATED key is historically valid); REVOKED never does.
* Consumers that require a CURRENT key must also check statusOf()==ACTIVE.
*/
function verifyWithKey(uint256 keyId, bytes32 message, bytes calldata signature) external view returns (bool) {
if (keyId >= _keys.length) return false;
KeyRecord storage k = _keys[keyId];
if (k.status == STATUS_REVOKED || k.status == STATUS_NONE) return false;
return _verify(k.scheme, k.pubKey, message, signature);
}
/**
* @notice Stateless PQC verification for any scheme over a raw 32-byte message. No
* registration or binding: `message` is used directly as the signed message,
* so anyone can check any NIST PQC signature via eth_call for free.
*/
function verify(uint8 scheme, bytes calldata pubKey, bytes32 message, bytes calldata signature)
external
view
returns (bool)
{
return _verify(scheme, pubKey, message, signature);
}
// ==========================================================================
// Internal verifier
// ==========================================================================
/// @dev Fail-closed: a malformed length or unknown scheme returns false (never
/// reverts), mirroring AereCryptoRegistry.verify. A well-formed-but-wrong
/// signature yields an input the precompile rejects with 0.
function _verify(uint8 scheme, bytes memory pubKey, bytes32 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 (identical layout to
/// AerePQCAttestation). Returns built=false on any length/scheme that cannot form
/// a valid input, so verification fails closed rather than reverting.
function _tryBuildInput(uint8 scheme, bytes memory pubKey, bytes32 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(32) || 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(32)
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(32)
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 keys (keyIds run 0..keyCount-1).
function keyCount() external view returns (uint256) {
return _keys.length;
}
/// @notice Read a registered key. Reverts on an unknown keyId.
function getKey(uint256 keyId)
external
view
returns (
address owner,
uint8 scheme,
uint8 status,
uint64 registeredBlock,
uint256 rotatedFromId,
uint256 rotatedToId,
bytes memory pubKey
)
{
if (keyId >= _keys.length) revert UnknownKey(keyId);
KeyRecord storage k = _keys[keyId];
return (k.owner, k.scheme, k.status, k.registeredBlock, k.rotatedFromId, k.rotatedToId, k.pubKey);
}
/// @notice Lifecycle status of a key (STATUS_NONE for a never-registered id).
function statusOf(uint256 keyId) external view returns (uint8) {
if (keyId >= _keys.length) return STATUS_NONE;
return _keys[keyId].status;
}
/// @notice True iff the key exists and is ACTIVE (current, not rotated/revoked).
function isActiveKey(uint256 keyId) external view returns (bool) {
if (keyId >= _keys.length) return false;
return _keys[keyId].status == STATUS_ACTIVE;
}
/// @notice The scheme of a key, or 0 for an unknown id.
function schemeOf(uint256 keyId) external view returns (uint8) {
if (keyId >= _keys.length) return 0;
return _keys[keyId].scheme;
}
/// @notice The address that owns/controls a key (zero for an unknown id).
function ownerOf(uint256 keyId) external view returns (address) {
if (keyId >= _keys.length) return address(0);
return _keys[keyId].owner;
}
/// @notice All keyIds ever registered by `owner` (append-only, includes retired).
function keysOf(address owner) external view returns (uint256[] memory) {
return _identityKeys[owner];
}
/// @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);
}
}