aere-contracts/contracts/modular/AerePQCKeyExchange.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

346 lines
21 KiB
Solidity

// SPDX-License-Identifier: MIT
pragma solidity 0.8.23;
/// @title AerePQCKeyExchange - on-chain registry + confirmed handshake for ML-KEM-768 key establishment
/// @notice A coordination and tamper-evident ledger that lets two parties establish a POST-QUANTUM
/// shared secret with ML-KEM-768 (FIPS 203, a NIST Module-Lattice KEM) and then key encrypted
/// off-chain payloads from it. The confidentiality is quantum-safe: a "harvest-now,
/// decrypt-later" adversary that records the chain cannot recover the secret, unlike an
/// ECIES / X25519 envelope a future quantum computer would open.
///
/// FLOW (heavy KEM/KDF/AEAD math run OFF-CHAIN or in the precompile; this contract stores
/// only public keys, ciphertext HASHES, a commitment, handshake state and a timeout):
/// 1. A RECIPIENT registers (or rotates) its ML-KEM-768 encapsulation key `ek` via
/// registerKemKey / rotateKemKey. The contract stores keccak256(ek) and emits the full
/// key, so any counterparty can fetch it from logs and verify it against the on-chain
/// hash.
/// 2. An INITIATOR, off-chain, runs ML-KEM.Encaps(ek) -> (ciphertext c, shared secret K).
/// Where the ML-KEM precompile 0x0AE6 is activated it can recompute the deterministic
/// transcript; otherwise any FIPS-203 library produces the same (c, K). The initiator
/// derives a one-way CONFIRMATION OPENING kc = keccak256(abi.encode(CONFIRM_DOMAIN,
/// chainid, this, initiator, responder, ekHash, keccak256(c), K)) and calls
/// initiateHandshake with the full c (emitted for the recipient), keccak256(c), and the
/// confirmation COMMITMENT confirmCommit = keccak256(abi.encode(kc)). K and kc are NOT
/// posted. A timeout window is armed.
/// 3. The RECIPIENT, holding the decapsulation (secret) key, fetches c from the logs,
/// checks keccak256(c) == the stored kemCtHash, runs ML-KEM.Decaps to recover K, and
/// recomputes kc. It calls confirmHandshake(id, kc). The contract checks
/// keccak256(abi.encode(kc)) == confirmCommit and only then marks the handshake Confirmed
/// and records kc. Because kc is a one-way function of the secret K and was NEVER
/// published (only its hash was), a party can produce a matching kc ONLY by knowing K,
/// i.e. by actually decapsulating the ciphertext. Confirmation is therefore an on-chain,
/// transcript-bound PROOF THAT THE CONFIRMER DERIVED THE SAME SHARED SECRET.
/// 4. With the handshake Confirmed, both parties key an AEAD (e.g. AES-256-GCM) from K via a
/// KDF under a DISTINCT domain and exchange encrypted payloads off-chain. The on-chain
/// Confirmed state and revealed kc are the mutually-agreed, tamper-evident anchor.
/// 5. If the recipient never confirms inside the window, the handshake can be cancelled: the
/// initiator may abort at any time while pending, and ANYONE may reap it once the deadline
/// passes (timeout-cancel). A cancelled or timed-out handshake can never later confirm.
///
/// WHY REVEALING kc IS SAFE FOR CONFIDENTIALITY. kc = keccak256(...|| K) is a one-way
/// function of K, and K is a 32-byte (256-bit) high-entropy ML-KEM shared secret, so kc is
/// neither invertible nor brute-forceable back to K. The symmetric key that actually protects
/// the off-chain payload is derived as KDF(K) under a SEPARATE domain, independent of kc.
/// Publishing kc (and its commitment) therefore leaks nothing usable about K or the payload
/// key. This is the same reasoning that keeps the KEM OFF the on-chain hot path (see below).
///
/// WHY THE KEM IS NOT RUN ON-CHAIN. The ML-KEM precompile 0x0AE6 performs a DETERMINISTIC
/// Encaps(ek, m) -> (c, K). Verifying a transcript on-chain would require supplying the
/// encapsulation coins m, from which anyone could recompute K and decrypt every payload,
/// destroying the very confidentiality this rail exists to provide. So the KEM stays
/// off-chain / in the precompile off the confidential path, and the chain holds only the
/// ciphertext hash and the commit-reveal confirmation. This mirrors the sibling
/// AerePQCEncryptedIntent rail.
///
/// HONEST SCOPE AND TRUST MODEL.
/// - DEPLOYABILITY. This contract targets AERE's ML-KEM-768 precompile at 0x0AE6, which is
/// TESTNET-ONLY: it is BUILT and KAT-verified on an isolated QBFT testnet but is NOT yet
/// activated on AERE mainnet (chain 2800). Mainnet precompile activation is founder-gated.
/// So this contract is DEPLOYABLE ON TESTNET NOW and MAINNET-PENDING-ACTIVATION. Note also
/// that the contract itself never calls 0x0AE6 (the KEM is off the hot path, above), so it
/// is functionally deployable anywhere; the 0x0AE6 dependency is on the COUNTERPARTY tooling
/// that produces the ML-KEM transcript, which is why testnet is the honest deployment
/// surface today.
/// - CONSENSUS IS UNCHANGED. AERE consensus remains classical ECDSA QBFT (Foundation
/// validators). What is post-quantum here is the CONFIDENTIALITY of the established secret
/// (ML-KEM key transport). This contract adds NO consensus claim and no post-quantum
/// consensus claim.
/// - WHAT THE CHAIN GUARANTEES. (a) A recipient's advertised ML-KEM key is bound to an
/// on-chain hash any counterparty can verify; (b) a handshake references a specific
/// ciphertext (by hash) and a specific recipient key epoch, so a stale or rotated key
/// cannot be silently substituted; (c) confirmation is possible ONLY by a party that
/// derived the same shared secret (commit-reveal of a one-way opening), giving both sides a
/// non-repudiable record that the KEM completed; (d) an unconfirmed handshake always
/// resolves via timeout-cancel, so neither side is left in limbo.
/// - WHAT THE CHAIN DOES NOT DO. It does not transport, decrypt, or escrow the payload, does
/// not run the KEM, and cannot force either party to actually use K honestly off-chain. It
/// is a key-establishment COORDINATION and CONFIRMATION layer, not a message bus.
contract AerePQCKeyExchange {
// ---- ML-KEM-768 (FIPS 203) precompile: reference constants -----------------
/// @notice Address of AERE's ML-KEM-768 deterministic-Encaps precompile (input ek(1184)||m(32),
/// output c(1088)||K(32)). TESTNET-ONLY today: activated on an isolated QBFT testnet and
/// KAT-verified, NOT yet activated on AERE mainnet chain 2800 (founder-gated). Recorded
/// here for off-chain / audit reference; this contract does NOT call it (KEM stays off the
/// confidential hot path, see the contract NatSpec).
address public constant ML_KEM_768_PRECOMPILE = 0x0000000000000000000000000000000000000AE6;
/// @notice ML-KEM-768 encapsulation-key (public key) length in bytes, FIPS 203.
uint256 public constant ML_KEM_768_EK_LEN = 1184;
/// @notice ML-KEM-768 KEM-ciphertext length in bytes, FIPS 203.
uint256 public constant ML_KEM_768_CT_LEN = 1088;
/// @notice ML-KEM-768 shared-secret length in bytes, FIPS 203.
uint256 public constant ML_KEM_768_SS_LEN = 32;
/// @notice Domain separator for the confirmation opening kc, distinct from any other AERE PQC
/// domain so a value derived here can never collide with a payload-encryption key or a
/// signature challenge. Clients fold this (plus the transcript and K) into kc.
bytes32 public constant CONFIRM_DOMAIN = keccak256("AerePQCKeyExchange.v1.confirm");
// ---- Handshake timeout window ---------------------------------------------
uint64 public constant MIN_HANDSHAKE_WINDOW = 1 minutes;
uint64 public constant MAX_HANDSHAKE_WINDOW = 30 days;
enum HandshakeState {
None, // never created
Initiated, // ciphertext + commitment posted; awaiting the recipient's confirm
Confirmed, // recipient revealed a matching opening; shared secret established (terminal)
Cancelled // aborted by the initiator, or reaped after timeout (terminal)
}
/// @notice A party's currently advertised ML-KEM-768 encapsulation key.
struct KemKey {
bytes32 keyHash; // keccak256 of the advertised ek
uint64 epoch; // increments on each register/rotate so clients can detect rotation
bool active; // false once retired; no new handshake may target it
}
/// @notice One handshake's on-chain state. The ciphertext bytes live in the initiate calldata and
/// event log, NOT in storage; storage keeps only their hash plus the commitment.
struct Handshake {
address initiator; // the party that encapsulated and posted the ciphertext
address responder; // the recipient key owner: the only address that may confirm
uint64 initiatedAt; // block.timestamp at initiate
uint64 deadline; // confirm must land at or before this; after it, timeout-cancel is open
HandshakeState state;
bytes32 ekHash; // the recipient encapsulation key epoch the initiator encapsulated to
bytes32 kemCtHash; // keccak256 of the ML-KEM-768 ciphertext c
bytes32 confirmCommit; // keccak256(abi.encode(kc)); kc is the one-way, K-derived opening
bytes32 confirmOpening;// kc, revealed by the responder at confirm (0 until Confirmed)
}
/// @notice party address => its advertised encapsulation key.
mapping(address => KemKey) public kemKeyOf;
/// @notice handshakeId => handshake record.
mapping(uint256 => Handshake) internal _handshakes;
/// @notice next handshake id to assign (ids start at 1; 0 is reserved for "none").
uint256 public nextHandshakeId = 1;
// ---- Events ----------------------------------------------------------------
event KemKeyRegistered(address indexed party, bytes32 indexed keyHash, uint64 epoch, bytes ek);
event KemKeyRotated(address indexed party, bytes32 indexed keyHash, uint64 epoch, bytes ek);
event KemKeyRetired(address indexed party, bytes32 indexed keyHash);
event HandshakeInitiated(
uint256 indexed handshakeId,
address indexed initiator,
address indexed responder,
bytes32 ekHash,
bytes32 kemCtHash,
bytes32 confirmCommit,
uint64 deadline,
bytes kemCiphertext
);
event HandshakeConfirmed(uint256 indexed handshakeId, address indexed responder, bytes32 confirmOpening);
event HandshakeCancelled(uint256 indexed handshakeId, address indexed by, bool timedOut);
// ---- Errors ----------------------------------------------------------------
error BadEncapKeyLength(uint256 got, uint256 want);
error KeyAlreadyActive();
error NoActiveKey(address party);
error ResponderKeyInactive(address responder);
error StaleEncapKey(bytes32 got, bytes32 current);
error BadCiphertextLength(uint256 got, uint256 want);
error ZeroCommitment();
error HandshakeWindowOutOfRange(uint64 got);
error UnknownHandshake(uint256 handshakeId);
error WrongState(HandshakeState got, HandshakeState want);
error NotResponder(address caller, address responder);
error HandshakeExpired(uint64 deadline, uint256 nowTs);
error ConfirmMismatch(bytes32 got, bytes32 want);
error NotCancellable(uint256 handshakeId);
// ============================================================================
// ML-KEM key registry (register / rotate / retire)
// ============================================================================
/// @notice Register the caller's ML-KEM-768 encapsulation key for the FIRST time (or again after a
/// retire). Stores keccak256(ek) and emits the full key so counterparties can fetch and
/// verify it trustlessly from logs. Reverts if the caller already has an ACTIVE key; use
/// rotateKemKey to replace an active key in place.
/// @param ek the FIPS 203 ML-KEM-768 encapsulation key (exactly 1184 bytes).
function registerKemKey(bytes calldata ek) external returns (bytes32 keyHash) {
if (kemKeyOf[msg.sender].active) revert KeyAlreadyActive();
keyHash = _setKemKey(ek);
emit KemKeyRegistered(msg.sender, keyHash, kemKeyOf[msg.sender].epoch, ek);
}
/// @notice Rotate the caller's ACTIVE ML-KEM-768 key in place, bumping its epoch. Any handshake
/// already Initiated against the OLD epoch keeps its own bound ekHash and can still be
/// confirmed (the caller must retain the matching decapsulation key), so an in-flight
/// handshake is never griefed by a rotation. New handshakes must target the new key.
/// @param ek the new FIPS 203 ML-KEM-768 encapsulation key (exactly 1184 bytes).
function rotateKemKey(bytes calldata ek) external returns (bytes32 keyHash) {
if (!kemKeyOf[msg.sender].active) revert NoActiveKey(msg.sender);
keyHash = _setKemKey(ek);
emit KemKeyRotated(msg.sender, keyHash, kemKeyOf[msg.sender].epoch, ek);
}
/// @notice Retire the caller's advertised key so no NEW handshake can target it. Handshakes already
/// Initiated against it are unaffected (they carry their own ekHash) and can still be
/// confirmed or cancelled.
function retireKemKey() external {
KemKey storage k = kemKeyOf[msg.sender];
if (!k.active) revert NoActiveKey(msg.sender);
k.active = false;
emit KemKeyRetired(msg.sender, k.keyHash);
}
/// @dev Shared register/rotate body: length-check, hash, bump epoch, mark active.
function _setKemKey(bytes calldata ek) internal returns (bytes32 keyHash) {
if (ek.length != ML_KEM_768_EK_LEN) revert BadEncapKeyLength(ek.length, ML_KEM_768_EK_LEN);
keyHash = keccak256(ek);
KemKey storage k = kemKeyOf[msg.sender];
k.keyHash = keyHash;
k.epoch += 1;
k.active = true;
}
// ============================================================================
// Handshake lifecycle (initiate / confirm / cancel)
// ============================================================================
/// @notice Initiate a handshake to `responder` by posting an ML-KEM-768 ciphertext (encapsulated
/// off-chain to the responder's advertised key) plus a confirmation commitment. The full
/// `kemCiphertext` is passed in calldata and emitted for the responder to fetch; only its
/// hash and the commitment are stored. Arms a timeout window.
/// @param responder the recipient (must have an active advertised key).
/// @param ekHash keccak256 of the responder key the initiator encapsulated to; must equal
/// the responder's CURRENT advertised keyHash (guards against encrypting to a
/// stale/rotated key).
/// @param kemCiphertext the ML-KEM-768 ciphertext c (exactly 1088 bytes).
/// @param confirmCommit keccak256(abi.encode(kc)), where kc is the one-way, K-derived confirmation
/// opening (see contract NatSpec). Nonzero. Never post kc or K here.
/// @param window seconds the responder has to confirm, in
/// [MIN_HANDSHAKE_WINDOW, MAX_HANDSHAKE_WINDOW].
function initiateHandshake(
address responder,
bytes32 ekHash,
bytes calldata kemCiphertext,
bytes32 confirmCommit,
uint64 window
) external returns (uint256 handshakeId) {
KemKey memory rk = kemKeyOf[responder];
if (!rk.active) revert ResponderKeyInactive(responder);
if (ekHash != rk.keyHash) revert StaleEncapKey(ekHash, rk.keyHash);
if (kemCiphertext.length != ML_KEM_768_CT_LEN) {
revert BadCiphertextLength(kemCiphertext.length, ML_KEM_768_CT_LEN);
}
if (confirmCommit == bytes32(0)) revert ZeroCommitment();
if (window < MIN_HANDSHAKE_WINDOW || window > MAX_HANDSHAKE_WINDOW) {
revert HandshakeWindowOutOfRange(window);
}
uint64 deadline = uint64(block.timestamp) + window;
bytes32 kemCtHash = keccak256(kemCiphertext);
handshakeId = nextHandshakeId++;
_handshakes[handshakeId] = Handshake({
initiator: msg.sender,
responder: responder,
initiatedAt: uint64(block.timestamp),
deadline: deadline,
state: HandshakeState.Initiated,
ekHash: ekHash,
kemCtHash: kemCtHash,
confirmCommit: confirmCommit,
confirmOpening: bytes32(0)
});
emit HandshakeInitiated(
handshakeId, msg.sender, responder, ekHash, kemCtHash, confirmCommit, deadline, kemCiphertext
);
}
/// @notice Confirm a handshake by revealing the confirmation opening kc. Only the designated
/// responder may call, and only at or before the deadline. The contract checks
/// keccak256(abi.encode(kc)) == the stored commitment; on match the handshake becomes
/// Confirmed and kc is recorded. Because kc was never published (only its hash was) and is
/// a one-way function of the shared secret K, a matching kc proves the caller derived the
/// same K, i.e. actually decapsulated the ciphertext. A wrong opening reverts and leaves
/// the handshake untouched (the responder may retry within the window).
/// @param handshakeId the handshake to confirm.
/// @param kc the confirmation opening: keccak256(abi.encode(CONFIRM_DOMAIN, chainid, this,
/// initiator, responder, ekHash, kemCtHash, K)); see contract NatSpec.
function confirmHandshake(uint256 handshakeId, bytes32 kc) external {
Handshake storage h = _handshakes[handshakeId];
if (h.state == HandshakeState.None) revert UnknownHandshake(handshakeId);
if (h.state != HandshakeState.Initiated) revert WrongState(h.state, HandshakeState.Initiated);
if (msg.sender != h.responder) revert NotResponder(msg.sender, h.responder);
if (block.timestamp > h.deadline) revert HandshakeExpired(h.deadline, block.timestamp);
bytes32 got = keccak256(abi.encode(kc));
if (got != h.confirmCommit) revert ConfirmMismatch(got, h.confirmCommit);
h.state = HandshakeState.Confirmed;
h.confirmOpening = kc;
emit HandshakeConfirmed(handshakeId, msg.sender, kc);
}
/// @notice Cancel a pending (Initiated) handshake. Two cases:
/// - the INITIATOR may abort at any time while the handshake is still pending; or
/// - ANYONE may reap it once block.timestamp > deadline (permissionless timeout-cancel).
/// A Confirmed or already-Cancelled handshake cannot be cancelled. After cancel the
/// handshake is terminal and can never confirm, so a set of handshake parameters is
/// single-use.
function cancelHandshake(uint256 handshakeId) external {
Handshake storage h = _handshakes[handshakeId];
if (h.state == HandshakeState.None) revert UnknownHandshake(handshakeId);
if (h.state != HandshakeState.Initiated) revert WrongState(h.state, HandshakeState.Initiated);
bool timedOut = block.timestamp > h.deadline;
// Only the initiator may abort early; anyone may reap a timed-out handshake.
if (!timedOut && msg.sender != h.initiator) revert NotCancellable(handshakeId);
h.state = HandshakeState.Cancelled;
emit HandshakeCancelled(handshakeId, msg.sender, timedOut);
}
// ============================================================================
// Views
// ============================================================================
/// @notice The full handshake record (reverts on an unknown id).
function getHandshake(uint256 handshakeId) external view returns (Handshake memory) {
Handshake memory h = _handshakes[handshakeId];
if (h.state == HandshakeState.None) revert UnknownHandshake(handshakeId);
return h;
}
/// @notice The state of a handshake (HandshakeState.None for an unknown id).
function handshakeState(uint256 handshakeId) external view returns (HandshakeState) {
return _handshakes[handshakeId].state;
}
/// @notice Whether a timeout-cancel would currently succeed for this handshake (Initiated and past
/// its deadline).
function isTimedOut(uint256 handshakeId) external view returns (bool) {
Handshake memory h = _handshakes[handshakeId];
return h.state == HandshakeState.Initiated && block.timestamp > h.deadline;
}
/// @notice The commitment a client must post for a given confirmation opening kc, for parity checks
/// against off-chain code. Equals what confirmHandshake reproduces from a revealed kc.
function confirmCommitFor(bytes32 kc) external pure returns (bytes32) {
return keccak256(abi.encode(kc));
}
}