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.
412 lines
19 KiB
Solidity
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 audited 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);
|
|
}
|
|
}
|