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.
346 lines
21 KiB
Solidity
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));
|
|
}
|
|
}
|