--- eip: 8450 title: Post-Quantum Validator Key Registry description: Register dormant hash-based consensus keys for every validator ahead of the post-quantum signature switch author: Thomas Coratger (@tcoratger), Kevaundray Wedderburn (@kevaundray), Tom Wambsgans (@TomWambsgans), Justin Drake (@JustinDrake) discussions-to: https://ethereum-magicians.org/t/TBD/0 status: Draft type: Standards Track category: Core created: 2026-10-15 requires: 7002, 7685, 7916 --- ## Abstract This EIP adds a post-quantum public key registry to the beacon state. Each validator registers one hash-based public key, bound to its existing validator index: a leanXMSS key, used for every message the validator signs once duties switch to it. The leanXMSS key is a two-layer tree: a top tree of `2**16` one-time keys, each of which certifies a bottom tree of `2**16` one-time keys, for a lifetime of `2**32` leaf indices. No step of key generation handles more than `2**16` one-time keys. Every signature carries its leaf index, and the signer chooses how leaf indices are allocated to messages, provided no leaf index signs two different messages. All hashing uses Keccak-256. Registration is a new beacon block operation carrying the public key and a proof of possession under it. It is authorized either by the validator's current BLS signing key (register-only) or by the validator's withdrawal address through a new [EIP-7685](./eip-7685.md) request (register or replace). The registered keys are dormant: no duty is authenticated with them in this fork. A later fork switches duties to them. For that fork, the registry provides the fraction of stake with registered keys, and a post-quantum public key for every registered validator. ## Motivation Every consensus-layer signature today is a BLS12-381 signature: attestations, block proposals, RANDAO reveals, aggregation selection proofs, sync committee messages, payload attestations, inclusion lists, proposer preferences, voluntary exits, and the deposit proof of possession. BLS relies on the hardness of discrete logarithms in pairing-friendly groups, which Shor's algorithm breaks. A cryptographically relevant quantum computer (CRQC) recovers a validator's secret key from its public key, and every validator's public key is in the beacon state. BLS therefore has to be replaced, and this EIP splits the replacement into two forks. Generating a post-quantum key involves the validator's secret key material, requires new tooling, and, for staking pools, requires coordination between node operators and stake owners. If this work happens at the fork that switches the signature scheme, the number of ready validators is only known at that fork. If a CRQC appears before the switch, no post-quantum key exists to fall back on, and any message signed with BLS after that point may be forged. A separate registry fork has the following benefits: - **Preparation.** Validators generate and register keys, with tooling tested on mainnet, while BLS remains the only scheme used for duties. - **A readiness signal.** The fraction of stake with registered keys can be read from the state, so the switch can be scheduled against an observed threshold. - **A recovery anchor.** Keys registered while BLS is secure are authentic. If BLS breaks before the switch, the chain can move to the registered keys without relying on any BLS signature made after the break. - **Early implementation.** Consensus clients start manipulating the hash function, the verifier, and the containers of the post-quantum scheme before any duty depends on them, because every registration carries a proof of possession that clients verify. Each validator registers one leanXMSS key for all of its messages. The [Rationale](#one-key-for-all-duties) explains this choice and the two ways a signer can allocate leaf indices. ## Specification The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119) and [RFC 8174](https://www.rfc-editor.org/rfc/rfc8174). The consensus-layer changes apply to the consensus specifications (`ethereum/consensus-specs`) on top of Heze. The execution-layer changes apply on top of the execution-layer fork paired with it. The fork that activates this EIP is assigned by the relevant meta EIP. Functions and constants not defined here keep their meaning from those specifications. ### Cryptographic primitives #### Hash function All post-quantum hashing in this EIP uses `keccak256`, the Keccak-256 hash function of the execution layer, in the cell encoding of leanVM. The input is split into 128-byte chunks, the last one possibly shorter, and consecutive chunks are joined by the 8 bytes `CELL_GAP`. Inputs of at most 128 bytes are therefore hashed unchanged. On longer inputs, each 136-byte Keccak-256 block carries 128 input bytes, which keeps the 16-byte cells of the leanVM verifier aligned. ```python CELL_GAP = bytes(7) + b"\x80" def lean_hash(data: bytes) -> Bytes32: """ Hash bytes with Keccak-256 in the cell encoding. The input is cut into 128-byte chunks. An 8-byte gap separates consecutive chunks. Each 136-byte Keccak block then holds exactly 128 input bytes. """ # Cut into 128-byte chunks. # An empty input is a single empty chunk. chunks = [data[i:i + 128] for i in range(0, len(data), 128)] or [b""] # Join with the gap and hash. # An input of at most 128 bytes is hashed unchanged. return keccak256(CELL_GAP.join(chunks)) def th(public_param: Hash16, address: bytes, message: bytes) -> Hash16: """ Hash one call of a key. The public parameter separates keys. The address separates the calls within a key. The output keeps the first 16 bytes of the hash. """ # Input layout: public parameter (16), address (16), message. return Hash16(lean_hash(bytes(public_param) + address + message)[:16]) ``` `public_param` is a 16-byte per-key public parameter. The address names the hash call within the key: ```python # Types of the hash calls a verifier makes. ADDRESS_CHAIN = 1 ADDRESS_LEAF = 2 ADDRESS_NODE = 3 ADDRESS_MESSAGE = 12 def xmss_address(kind: int, step: int, layer: int, high: int, low: int) -> bytes: """ Build the 16-byte address of one hash call. bytes 0..4 one-time key or node index bytes 4..7 tree level, with the layer in the top bit byte 7 call type + 32 * chain step bytes 8..16 zero All integers are little-endian. """ # The top layer sets bit 23 of the 3-byte field. high += 2**23 * layer return ( low.to_bytes(4, "little") + high.to_bytes(3, "little") + bytes([kind + 32 * step]) + bytes(8) ) ``` Address types `0`, `5` and `7` are used only by the signer, to derive the chain secrets, the public parameter, and the randomizer. #### leanXMSS leanXMSS is a two-layer XMSS ([RFC 8391](https://www.rfc-editor.org/rfc/rfc8391)) over target-sum Winternitz one-time keys (WOTS+C), in the generalized XMSS framework[^dkkw25], as specified by the leanXMSS specification of leanVM. Its parameters are: [^dkkw25]: ```csl-json { "id": "https://doi.org/10.62056/aey7qjp10", "type": "article-journal", "title": "Hash-Based Multi-Signatures for Post-Quantum Ethereum", "author": [ { "given": "Justin", "family": "Drake" }, { "given": "Dmitry", "family": "Khovratovich" }, { "given": "Mikhail", "family": "Kudinov" }, { "given": "Benedikt", "family": "Wagner" } ], "issued": { "date-parts": [[2025]] }, "container-title": "IACR Communications in Cryptology", "volume": "2", "issue": "1", "DOI": "10.62056/aey7qjp10", "URL": "https://doi.org/10.62056/aey7qjp10", "custom": { "additional-urls": [ "https://eprint.iacr.org/2025/055" ] } } ``` | Name | Value | Description | | - | - | - | | `XMSS_CHAINS` | `58` | Hash chains per one-time key, each with positions `0` to `3` | | `XMSS_TARGET_SUM` | `106` | Sum of the digits of every codeword | | `XMSS_TREE_HEIGHT` | `16` | Height of the trees of each layer | | digest, public parameter, randomizer | `16` bytes each | Size of every chain value, Merkle node, `P` and `rho` | | message | `32` bytes | A message hash, here an SSZ signing root | The key has two layers: - **The bottom layer** (`layer = 0`) holds `2**32` one-time keys, grouped into `2**16` bottom trees of height 16. One-time key `i` belongs to bottom tree `i >> 16`. Node indices on the bottom layer are global across its trees. - **The top layer** (`layer = 1`) is one tree of height 16 over `2**16` one-time keys. Top key `t` signs the 16-byte root of bottom tree `t`, and nothing else. - **The public key** is the root of the top tree followed by the public parameter `P`: 32 bytes. A signature at leaf index `i` carries `i`, the bottom one-time signature of the message by bottom key `i` with its path, and the top one-time signature of the root of bottom tree `i >> 16` by top key `i >> 16` with its path. The secret key is a 32-byte seed. Key generation, signing, and the derivation of `P` and of the randomizer are specified by leanXMSS and are not consensus-critical. Signing is deterministic: signing the same message at the same leaf index twice produces a byte-identical signature. Finding a codeword takes about `2**20` randomizer trials per signature. #### Functions ```python def xmss_codeword(digest: Hash16) -> Optional[Sequence[int]]: """ Read a digest as a codeword, or return None. The 16 bytes give 64 base-4 digits, least significant first. A codeword has its last 6 digits at zero and its first 58 summing to 106. The fixed sum blocks forgeries. Raising one digit forces lowering another, which needs a hash preimage. """ # 128 bits become 64 digits of 2 bits each. value = int.from_bytes(digest, "little") digits = [(value >> (2 * i)) & 3 for i in range(64)] # Unused digits must be zero, and the used ones must hit the target sum. if any(digits[XMSS_CHAINS:]) or sum(digits[:XMSS_CHAINS]) != XMSS_TARGET_SUM: return None return digits[:XMSS_CHAINS] ``` ```python def xmss_layer_leaf( public_param: Hash16, layer: int, key: int, message: bytes, signature: XMSSLayerSignature ) -> Optional[Hash16]: """ Recover the leaf of one one-time key from its signature of a message. Returns None when the message digest is not a codeword. Each revealed chain value is walked up to the end of its chain. The leaf commits to all 58 chain ends. """ # Digest of the message under the signer's randomizer. digest = th( public_param, xmss_address(ADDRESS_MESSAGE, 0, layer, 0, key), message + bytes(signature.randomness), ) # The digits say which position each chain reveals. digits = xmss_codeword(digest) if digits is None: return None ends = b"" for chain, (digit, value) in enumerate(zip(digits, signature.chain_values)): # Walk from the revealed position to the end, position 3. # # position: 0 1 2 3 # digit 1 : ^ ---> ---> end (2 steps) for step in range(digit, 3): # Each step also hashes the chain number. value = th( public_param, xmss_address(ADDRESS_CHAIN, step, layer, 0, key), bytes(value) + chain.to_bytes(16, "little"), ) ends += bytes(value) # Hash the 58 chain ends into the leaf. return th(public_param, xmss_address(ADDRESS_LEAF, 0, layer, 0, key), ends) ``` ```python def xmss_layer_root( public_param: Hash16, layer: int, key: int, leaf: Hash16, path: Sequence[Hash16] ) -> Hash16: """ Fold an authentication path from a leaf up to the root of its tree. The path holds one sibling per level, from the leaf upward. On the bottom layer, node indices are global across all bottom trees. """ node = leaf for level, sibling in enumerate(path): # The key's bit at this level says which side the current node is on. # # bit 0: hash(node, sibling) # bit 1: hash(sibling, node) pair = bytes(node) + bytes(sibling) if (key >> level) & 1: pair = bytes(sibling) + bytes(node) # The parent's address names its level and its index at that level. address = xmss_address(ADDRESS_NODE, 0, layer, level + 1, key >> (level + 1)) node = th(public_param, address, pair) return node ``` ```python def xmss_verify(public_key: XMSSPublicKey, message: Bytes32, signature: XMSSSignature) -> bool: """ Verify a two-layer leanXMSS signature of a 32-byte message. message --bottom key i--> root of bottom tree i >> 16 that root --top key i >> 16--> top root, compared with the public key The signature carries its own leaf index. """ # The public key is the top root followed by the public parameter. top_root, public_param = Hash16(public_key[:16]), Hash16(public_key[16:]) index = int(signature.leaf_index) # The bottom tree holding this leaf, which is also the top key that certifies it. tree = index >> XMSS_TREE_HEIGHT # The bottom layer signs the message bottom_leaf = xmss_layer_leaf(public_param, 0, index, bytes(message), signature.bottom) if bottom_leaf is None: return False bottom_root = xmss_layer_root( public_param, 0, index, bottom_leaf, signature.bottom.merkle_path ) # The top layer certifies the bottom tree's root top_leaf = xmss_layer_leaf(public_param, 1, tree, bytes(bottom_root), signature.top) if top_leaf is None: return False return xmss_layer_root(public_param, 1, tree, top_leaf, signature.top.merkle_path) == top_root ``` ### Constants #### Domains | Name | Value | Description | | - | - | - | | `DOMAIN_PQ_KEY_REGISTRATION` | `DomainType('0x12000000')` | BLS authorization of a registration by the validator's signing key | | `DOMAIN_PQ_KEY_POSSESSION` | `DomainType('0x13000000')` | Proof of possession under the registered leanXMSS key | Both domains are computed against the genesis fork version, as for `BLSToExecutionChange`, so that registrations remain valid across forks. #### Registry | Name | Value | Description | | - | - | - | | `PQ_KEYS_VERSION_UNSET` | `Uint8(0)` | Marks a validator with no registered keys | | `PQ_KEYS_VERSION_1` | `Uint8(1)` | Two-layer leanXMSS over Keccak-256, as specified above | | `PQ_POSSESSION_XMSS_INDEX` | `Uint32(0)` | Leaf index at which the proof of possession is signed | | `PQ_KEY_AUTHORIZATION_REQUEST_TYPE` | `Bytes1('0x06')` | [EIP-7685](./eip-7685.md) request type of a key authorization | The request type value is provisional and MUST be unique across all active [EIP-7685](./eip-7685.md) request types. Allocation is coordinated in the consensus specifications. ### Preset | Name | Value | Description | | - | - | - | | `MAX_PQ_KEY_REGISTRATIONS` | `Uint64(2**3)` (= 8) | Maximum registrations per beacon block | | `MAX_PQ_KEY_AUTHORIZATION_REQUESTS_PER_PAYLOAD` | `Uint64(2**4)` (= 16) | Maximum key authorization requests per execution payload | | `PQ_KEY_AUTHORIZATION_EXPIRY` | `Epoch(2**8)` (= 256) | Epochs during which an owner authorization can be consumed | ### Custom types | Name | SSZ equivalent | Description | | - | - | - | | `Hash16` | `ByteVector[16]` | A Keccak-256 output truncated to 16 bytes: a chain value, a Merkle node, a randomizer, or the public parameter `P` | | `XMSSPublicKey` | `ByteVector[32]` | Top-tree root followed by the public parameter | ### Containers #### New containers ```python class XMSSLayerSignature(Container): """ One layer of a leanXMSS signature. A one-time signature and the path from its leaf to the root of its tree. Serializes to 16 * (1 + 58 + 16) = 1200 bytes. """ # Randomizer that makes the message digest a codeword. randomness: Hash16 # One value per chain, at the position its digit names. chain_values: Vector[Hash16, XMSS_CHAINS] # Siblings from the leaf up to the root of its tree. merkle_path: Vector[Hash16, XMSS_TREE_HEIGHT] ``` ```python class XMSSSignature(Container): """ A two-layer leanXMSS signature. The bottom layer signs the message. The top layer certifies the root of the bottom tree. Serializes to 4 + 1200 + 1200 = 2404 bytes. """ # Leaf index the signature uses. # The high 16 bits name the bottom tree, the low 16 bits the leaf inside it. leaf_index: Uint32 # One-time signature of the message, with its path in the bottom tree. bottom: XMSSLayerSignature # One-time signature of the bottom tree's root, with its path in the top tree. top: XMSSLayerSignature ``` The SSZ serialization of an `XMSSSignature` is exactly the leanXMSS signature: the leaf index, then the bottom layer, then the top layer. ```python class PQPublicKeys(Container): """ The post-quantum key of one validator, as stored in the registry. """ # Parameter set of the key, or 0 when no key is registered. version: Uint8 # Root of the top tree followed by the public parameter. xmss_public_key: XMSSPublicKey ``` ```python class PQKeyRegistration(Container): """ The message of a registration: which validator, and which key. """ # Validator the key is registered for. # The proof of possession signs it, which ties the key to this validator. validator_index: ValidatorIndex # Key being registered. public_keys: PQPublicKeys ``` ```python class SignedPQKeyRegistration(Container): """ A registration as it appears in a beacon block. It carries the key, a proof that the registrant holds it, and the authorization. """ # Validator and key being registered. # The proof of possession signs it, and so does the BLS signature on the operator path. message: PQKeyRegistration # Signature of the registration by the registered key, at leaf index 0. # It proves that the registrant holds the secret key. possession: XMSSSignature # Who authorized the registration: # - a BLS signature by the validator's signing key, on the operator path; # - the BLS point at infinity, on the owner path. signature: BLSSignature ``` ```python class PQKeyAuthorizationRequest(Container): """ A key authorization made by a withdrawal address on the execution layer. """ # Address that called the execution-layer contract. source_address: ExecutionAddress # Validator the authorization is for, named by its BLS public key. validator_pubkey: BLSPubkey # Hash tree root of the authorized keys. public_keys_root: Root ``` ```python class PendingPQKeyAuthorization(Container): """ A key authorization waiting in the state for a matching registration. """ # Validator the authorization is for. validator_index: ValidatorIndex # Withdrawal address that authorized the keys. source_address: ExecutionAddress # Hash tree root of the authorized keys. # A registration must carry keys with this root. public_keys_root: Root # Last epoch at which a registration can use this authorization. expiry_epoch: Epoch ``` #### New list types ```python class ValidatorPQPublicKeys(ProgressiveList[PQPublicKeys]): """ Registered post-quantum keys, one entry per validator, by validator index. An entry with version 0 marks a validator without keys. """ ``` ```python class PendingPQKeyAuthorizations(ProgressiveList[PendingPQKeyAuthorization]): """ Key authorizations made by withdrawal addresses and not yet used. A validator has at most one pending authorization. """ ``` ```python class PQKeyRegistrations(ProgressiveList[SignedPQKeyRegistration]): """ Post-quantum key registrations included in a beacon block. """ ``` ```python class PQKeyAuthorizationRequests(ProgressiveList[PQKeyAuthorizationRequest]): """ Key authorization requests of an execution payload. """ ``` The lists are progressive ([EIP-7916](./eip-7916.md)), following the convention of the Gloas and Heze state. #### Modified containers `BeaconBlockBody` gains one field, appended after the existing fields: ```python class BeaconBlockBody(ProgressiveContainer): # ... existing fields ... pq_key_registrations: PQKeyRegistrations # [New in this EIP] ``` `BeaconState` gains two fields, appended after the existing fields. The `Validator` container is unchanged. ```python class BeaconState(ProgressiveContainer): # ... existing fields ... pq_public_keys: ValidatorPQPublicKeys # [New in this EIP] pending_pq_key_authorizations: PendingPQKeyAuthorizations # [New in this EIP] ``` `ExecutionRequests` gains one field, appended after the existing fields: ```python class ExecutionRequests(ProgressiveContainer): # ... existing fields ... pq_key_authorizations: PQKeyAuthorizationRequests # [New in this EIP] ``` `get_execution_requests_list` appends `(PQ_KEY_AUTHORIZATION_REQUEST_TYPE, execution_requests.pq_key_authorizations)` to its list of typed requests. ### Helpers ```python def is_pq_registered(state: BeaconState, index: ValidatorIndex) -> bool: """ Return whether a validator has registered post-quantum keys. """ # A validator without keys holds the all-zero entry, of version 0. return state.pq_public_keys[index].version != PQ_KEYS_VERSION_UNSET ``` ```python def get_pq_registered_balance(state: BeaconState) -> Gwei: """ Sum the effective balance of active validators with registered keys. This is the readiness signal for the signature switch. """ epoch = get_current_epoch(state) # Only active validators count toward readiness. return Gwei(sum( validator.effective_balance for index, validator in enumerate(state.validators) if is_active_validator(validator, epoch) and is_pq_registered(state, ValidatorIndex(index)) )) ``` No state transition in this EIP uses `get_pq_registered_balance`. It is the readiness signal against which the signature switch is scheduled, and clients SHOULD expose it. ```python def is_pq_public_key_in_use( state: BeaconState, public_keys: PQPublicKeys, excluded_index: ValidatorIndex ) -> bool: """ Return whether another validator already registered the same key. The validator being registered is excluded. It can therefore register its own current key again. """ for index, entry in enumerate(state.pq_public_keys): # Skip the validator itself and validators without keys. if index == excluded_index or entry.version == PQ_KEYS_VERSION_UNSET: continue # A match means the key belongs to someone else. if entry.xmss_public_key == public_keys.xmss_public_key: return True return False ``` Clients are expected to maintain a map from registered keys to validator indices, as they do for BLS public keys. ```python def get_pq_possession_signing_root(state: BeaconState, registration: PQKeyRegistration) -> Root: """ Return the message that the proof of possession signs. The domain uses the genesis fork version. A registration therefore stays valid across forks. """ domain = compute_domain( DOMAIN_PQ_KEY_POSSESSION, genesis_validators_root=state.genesis_validators_root ) return compute_signing_root(registration, domain) ``` ```python def is_valid_pq_possession_proof( state: BeaconState, registration: PQKeyRegistration, proof: XMSSSignature ) -> bool: """ Check that the holder of the registered key signed the registration. The registration contains the validator index. A copied key cannot be proven for a different index. """ # Leaf index 0 is reserved for the proof of possession. if proof.leaf_index != PQ_POSSESSION_XMSS_INDEX: return False # The registered key signs the registration itself. signing_root = get_pq_possession_signing_root(state, registration) return xmss_verify(registration.public_keys.xmss_public_key, signing_root, proof) ``` #### Modified `add_validator_to_registry` `add_validator_to_registry` appends an unset entry to `pq_public_keys`, preserving the invariant `len(state.pq_public_keys) == len(state.validators)`: ```python def add_validator_to_registry( state: BeaconState, pubkey: BLSPubkey, withdrawal_credentials: Bytes32, amount: Gwei ) -> None: index = get_index_for_new_validator(state) validator = get_validator_from_deposit(pubkey, withdrawal_credentials, amount) set_or_append_list(state.validators, index, validator) set_or_append_list(state.balances, index, amount) set_or_append_list(state.previous_epoch_participation, index, ParticipationFlags(0b0000_0000)) set_or_append_list(state.current_epoch_participation, index, ParticipationFlags(0b0000_0000)) set_or_append_list(state.inactivity_scores, index, Uint64(0)) set_or_append_list(state.pq_public_keys, index, PQPublicKeys()) # [New in this EIP] ``` `PQPublicKeys()` is the all-zero entry, whose version is `PQ_KEYS_VERSION_UNSET`. ### Block processing #### Modified `process_operations` ```python def process_operations(state: BeaconState, body: BeaconBlockBody, parent_slot: Slot) -> None: # ... existing length checks ... assert len(body.pq_key_registrations) <= MAX_PQ_KEY_REGISTRATIONS # [New in this EIP] # ... existing operation processing ... for_ops(body.pq_key_registrations, process_pq_key_registration) # [New in this EIP] ``` #### New `process_pq_key_registration` ```python def process_pq_key_registration( state: BeaconState, signed_registration: SignedPQKeyRegistration ) -> None: """ Register or replace the post-quantum keys of one validator. The BLS field selects the authorization: - the point at infinity: the withdrawal address authorized the keys; - a BLS signature: the validator's signing key authorized them. """ registration = signed_registration.message index = registration.validator_index public_keys = registration.public_keys assert index < len(state.validators) validator = state.validators[index] # Keys are registered only for validators that have not initiated an exit assert validator.exit_epoch == FAR_FUTURE_EPOCH assert public_keys.version == PQ_KEYS_VERSION_1 if signed_registration.signature == G2_POINT_AT_INFINITY: # Owner path: consume the withdrawal address's authorization of these keys authorization_position = get_pq_key_authorization_position(state, index) assert authorization_position is not None authorization = state.pending_pq_key_authorizations[authorization_position] assert authorization.public_keys_root == hash_tree_root(public_keys) assert authorization.expiry_epoch >= get_current_epoch(state) assert has_execution_withdrawal_credential(validator) assert validator.withdrawal_credentials[12:] == authorization.source_address remove_pq_key_authorization(state, authorization_position) else: # Operator path: register-only, authorized by the validator's signing key assert not is_pq_registered(state, index) domain = compute_domain( DOMAIN_PQ_KEY_REGISTRATION, genesis_validators_root=state.genesis_validators_root ) signing_root = compute_signing_root(registration, domain) assert bls.Verify(validator.pubkey, signing_root, signed_registration.signature) # Both paths require a unique key and a valid proof of possession. assert not is_pq_public_key_in_use(state, public_keys, index) assert is_valid_pq_possession_proof(state, registration, signed_registration.possession) # Write the keys into the validator's entry. state.pq_public_keys[index] = public_keys ``` The two authorization paths differ in what they may do: - **The operator path** registers keys for a validator that has none. It is authorized by the validator's BLS signing key, which is held by the validator's operator, and needs no execution-layer transaction. Once a validator has registered keys, the operator path cannot change them. - **The owner path** registers keys or replaces registered ones. It is authorized by the validator's withdrawal address, which already controls the stake and can exit the validator through [EIP-7002](./eip-7002.md). It is the only path that can replace keys, and is used to correct a lost, incorrectly generated, or forged registration. On both paths, the operation carries the full key and proof, can be submitted by anyone, and is verified in the same way. #### New `get_pq_key_authorization_position` and `remove_pq_key_authorization` ```python def get_pq_key_authorization_position(state: BeaconState, index: ValidatorIndex) -> Optional[int]: """ Return the position of a validator's pending authorization, or None. """ # A validator has at most one entry, so the first match is the only one. for position, authorization in enumerate(state.pending_pq_key_authorizations): if authorization.validator_index == index: return position return None ``` ```python def remove_pq_key_authorization(state: BeaconState, position: int) -> None: """ Remove the pending authorization at a given position. """ # Rebuild the list without that entry, keeping the order of the others. authorizations = list(state.pending_pq_key_authorizations) state.pending_pq_key_authorizations = PendingPQKeyAuthorizations( authorizations[:position] + authorizations[position + 1:] ) ``` ### Execution request processing #### Modified `apply_parent_execution_payload` `apply_parent_execution_payload` processes the key authorization requests of the parent payload after the existing requests: ```python def apply_parent_execution_payload(state: BeaconState, requests: ExecutionRequests) -> None: # ... existing length checks ... assert len(requests.pq_key_authorizations) <= MAX_PQ_KEY_AUTHORIZATION_REQUESTS_PER_PAYLOAD # [New in this EIP] # ... existing request processing ... for_ops(requests.pq_key_authorizations, process_pq_key_authorization_request) # [New in this EIP] # ... remainder unchanged ... ``` #### New `process_pq_key_authorization_request` As with other execution-layer requests, an invalid request is ignored and does not invalidate the block. ```python def process_pq_key_authorization_request( state: BeaconState, request: PQKeyAuthorizationRequest ) -> None: """ Record a withdrawal address's authorization of post-quantum keys. An invalid request is skipped. A valid one expires after 256 epochs. """ # Find the validator by its BLS public key. validator_pubkeys = [validator.pubkey for validator in state.validators] if request.validator_pubkey not in validator_pubkeys: return index = ValidatorIndex(validator_pubkeys.index(request.validator_pubkey)) validator = state.validators[index] # Only the withdrawal address may authorize keys if not has_execution_withdrawal_credential(validator): return if validator.withdrawal_credentials[12:] != request.source_address: return if validator.exit_epoch != FAR_FUTURE_EPOCH: return # A newer authorization replaces any pending one for the same validator position = get_pq_key_authorization_position(state, index) if position is not None: remove_pq_key_authorization(state, position) # Store the root of the authorized keys with its expiry epoch. state.pending_pq_key_authorizations.append( PendingPQKeyAuthorization( validator_index=index, source_address=request.source_address, public_keys_root=request.public_keys_root, expiry_epoch=get_current_epoch(state) + PQ_KEY_AUTHORIZATION_EXPIRY, ) ) ``` ### Epoch processing #### New `process_pending_pq_key_authorizations` Called from `process_epoch` after `process_registry_updates`. It drops the authorizations that have expired. ```python def process_pending_pq_key_authorizations(state: BeaconState) -> None: """ Drop the pending authorizations that cannot be used in the next epoch. """ # An authorization can be used up to and including its expiry epoch. next_epoch = Epoch(get_current_epoch(state) + 1) state.pending_pq_key_authorizations = PendingPQKeyAuthorizations([ authorization for authorization in state.pending_pq_key_authorizations if authorization.expiry_epoch >= next_epoch ]) ``` ### Fork transition At the fork epoch, the upgrade function initializes one unset entry per registry member and an empty authorization queue: ```python def upgrade_to_(pre: heze.BeaconState) -> BeaconState: post = BeaconState( # ... existing fields carried over from `pre` ... pq_public_keys=[PQPublicKeys() for _ in range(len(pre.validators))], # [New in this EIP] pending_pq_key_authorizations=[], # [New in this EIP] ) return post ``` ### Gossip A new global topic `pq_key_registration` carries `SignedPQKeyRegistration` messages. The following validations MUST pass before forwarding a `signed_registration` on the network, where `registration = signed_registration.message` and the head state is the node's state at its current head. They are listed in order, cheapest first. - **[REJECT]** `registration.public_keys.version` is `PQ_KEYS_VERSION_1`. - **[IGNORE]** `signed_registration` is the first message received with its `hash_tree_root`. - **[REJECT]** `registration.validator_index` is a validator in the head state, and its `exit_epoch` is `FAR_FUTURE_EPOCH`. - If `signed_registration.signature` is `G2_POINT_AT_INFINITY` (owner path): - **[IGNORE]** The head state holds an unexpired pending authorization for `registration.validator_index` whose `public_keys_root` is `hash_tree_root(registration.public_keys)`. Such an authorization exists only after the withdrawal address has paid the request fee on the execution layer. - Otherwise (operator path): - **[IGNORE]** `signed_registration` is the first valid operator-path registration received for `registration.validator_index`, as for `bls_to_execution_change`. - **[REJECT]** The validator has no registered keys in the head state. - **[REJECT]** `signed_registration.signature` is a valid BLS signature of `registration` by the validator's public key. - **[REJECT]** The key in `registration.public_keys` is not registered by another validator in the head state. - **[REJECT]** `signed_registration.possession` is a valid proof of possession of `registration`. ### Execution layer #### Key authorization request contract The key authorization request contract follows the [EIP-7002](./eip-7002.md) contract design: the same queue and storage layout, the same fee mechanism, the same end-of-block system call, and no Solidity-compatible ABI. It differs only in its input, its record, and the constants below. | Name | Value | Description | | - | - | - | | `PQ_KEY_AUTHORIZATION_REQUEST_PREDEPLOY_ADDRESS` | TBD | Address of the contract | | `PQ_KEY_AUTHORIZATION_REQUEST_TYPE` | `0x06` | [EIP-7685](./eip-7685.md) request type | | `MAX_PQ_KEY_AUTHORIZATION_REQUESTS_PER_BLOCK` | `16` | Records dequeued per block | | `TARGET_PQ_KEY_AUTHORIZATION_REQUESTS_PER_BLOCK` | `2` | Per-block count above which the fee rises | | `MIN_REQUEST_FEE` | `1` | Minimum fee, in wei | | `REQUEST_FEE_UPDATE_FRACTION` | `17` | Controls the fee's rate of change | A call from any address other than `SYSTEM_ADDRESS`, with exactly 80 bytes of calldata and `msg.value` at least the current fee, submits a request: | Bytes | Field | Description | | - | - | - | | `0:48` | `validator_pubkey` | BLS public key of the validator | | `48:80` | `public_keys_root` | `hash_tree_root` of the `PQPublicKeys` being authorized | The contract queues the record `source_address (20) ++ validator_pubkey (48) ++ public_keys_root (32)`, where `source_address` is `msg.sender`, and performs no further check. A call with empty calldata returns the current fee. The end-of-block system call dequeues up to `MAX_PQ_KEY_AUTHORIZATION_REQUESTS_PER_BLOCK` records, oldest first, and returns their concatenation as the request data, which is the SSZ serialization of a list of `PQKeyAuthorizationRequest`. If the system call fails, or if there is no code at the contract address once this EIP is active, the block MUST be invalid. The contract's bytecode and deployment transaction are to be provided with the reference implementation. ### Key roles The key registered under `PQ_KEYS_VERSION_1` is registered to sign every validator message once duties switch to it. This EIP does not authenticate any duty with it. A signature carries its leaf index, and `xmss_verify` accepts any leaf index. A validator's signer MUST NOT sign two different messages at the same leaf index. Leaf index `PQ_POSSESSION_XMSS_INDEX` is reserved for the proof of possession: a signer MUST NOT sign at it any message other than the registration it submits. The signer chooses how it allocates the other leaf indices to messages, as described in [Leaf allocation](#leaf-allocation). ## Rationale ### Registering before switching The reasons for separating the registry from the signature switch are given in the [Motivation](#motivation). The registered keys are not used for any duty. They cannot cause a missed attestation or a slashing, so the registry can be deployed before the aggregation and networking changes are ready. The fork that switches duties defines how the registered keys are used, including how a replacement of keys already in use takes effect. The operator path can only register keys. The owner path can replace them, before and after the switch. A validator that finds a bug in its key generation, or loses its post-quantum seed, replaces its keys through the owner path and does not need to exit. ### One key for all duties A stateful signature scheme signs at most one message per leaf index. Attestations fit this rule: one attestation per slot, and two different attestations in the same slot are slashable. Other duties do not: a proposer also attests in its slot, a sync committee member signs a message every slot in addition to its attestation, proposer preferences are re-signed after a reorganization, and voluntary exits are not tied to a slot. A second, stateless scheme such as leanSPHINCS would sign these messages without leaf management, at the cost of a second key, a second verifier, and larger signatures. This EIP registers one leanXMSS key instead, and leaves leaf allocation to the signer. The signature carries its leaf index, so the verifier needs no schedule, and new duties do not need a new key. The key is not used for randomness: hash-based signatures are not unique, and [EIP-8321](./eip-8321.md) replaces the BLS RANDAO reveal with a hash-chain commitment. ### Leaf allocation The signer allocates leaf indices to messages in one of two ways: - **Free allocation.** The signer gives each message the next unused leaf index and records it in its state. The key signs `2**32 - 1` messages in total, whatever the duties. - **Fixed allocation.** Each slot owns `k` consecutive leaf indices, `[k * s, k * s + k)` for slot `s`, one per duty the validator may perform in that slot. The schedule needs no state beyond slashing protection, and the lifetime becomes `2**32 / k` slots: with `k = 8`, `2**29` slots, about 204 years at 12-second slots. Both are compatible with the same registered key and the same verifier. Leaf index `0` falls in slot `0` under fixed allocation, the genesis slot, which is never a duty slot after this fork, so it is reserved for the proof of possession under both. Because signing is deterministic, a registration that is resubmitted, for example after its block was orphaned or under the other authorization path, carries a byte-identical proof and reuses no leaf. ### A two-layer XMSS key A flat XMSS tree of height 32 has a smaller signature, but its root depends on all `2**32` one-time keys: key generation takes about `9 * 10**11` Keccak-f permutations. Range keys cover only a window of indices and fill the rest of the tree with pseudo-random nodes. This reduces key generation, but fixes the key's lifetime at registration and still requires one large computation before registration. The two-layer tree limits each step to `2**16` one-time keys. The single-layer column is derived from the leanXMSS cost formulas with one tree of height 32: | | Flat tree, height 32 | Two layers, 16 + 16 | | - | - | - | | Key generation (public key) | `2**32` one-time keys | `2**16` one-time keys | | Ongoing work | None | One bottom tree of `2**16` one-time keys per `2**16` leaf indices | | Signature | 1460 bytes | 2404 bytes | | Verification | 109 Keccak-f permutations | 186 Keccak-f permutations | The public key, which is the only registered value, depends only on the `2**16` one-time keys of the top tree. A bottom tree is generated before its first leaf index is used. Hash-based schemes have no threshold variant, so a distributed validator generates and holds the key inside a multi-party computation, which then covers `2**16` one-time keys for the public key, against `2**32` for a flat tree. The leanXMSS specification sketches a threshold signer in which signing uses a threshold pseudorandom function, and multi-party computation is needed only at key generation and once per bottom tree. A signer holds at most the top tree and one bottom tree, each with `2**17 - 1` nodes of 16 bytes. The cost is one additional one-time signature per message: 944 more bytes of signature and 77 more Keccak-f permutations, so aggregators prove about 1.7 times as many permutations per signature. The split between the two layers is part of `PQ_KEYS_VERSION_1`, and a later version can change it. Registering each bottom root in the state would keep a one-layer signature, but adds about sixteen certifications per slot to every block with a million validators. ### Proof of possession Every registration proves possession of the key by signing the registration itself. The proof has three purposes: - **It detects faulty key generation at registration time.** Without it, a key that does not match its seed would only be detected at the signature switch, when the key is first used. - **It prevents key copying.** Attestation signatures cover `AttestationData`, and sync committee and payload timeliness signatures cover a block root. None of these signed messages contains the validator index. If two validators could register the same key, one could claim the other's signatures as its own participation. The registry therefore requires keys to be unique. - **It prevents squatting.** With uniqueness alone, an attacker could register a victim's published key first and block the victim's registration. The proof signs the validator index, so a copied key cannot be registered for a different index without the secret key. The proof is 2404 of the 2541 bytes of a registration. It is kept on chain because the signature switch relies on every registered key being a working key for its validator. ### Authorization by the signing key or by the withdrawal address The registry has to support solo stakers, staking pools whose withdrawal address is a contract, and distributed validators. This EIP therefore accepts two authorities. - **The withdrawal address** receives the stake and can exit the validator through [EIP-7002](./eip-7002.md). Allowing it to register and replace keys gives it no additional power. When the withdrawal address is a contract, the contract can implement the governance a pool needs. This path is as quantum-resistant as the account behind the address: a contract, or a post-quantum account under [EIP-8141](./eip-8141.md), remains secure after BLS and ECDSA are broken. - **The BLS signing key** is held by the validator's operator, who also generates and holds the post-quantum key. With this path, an operator does not need a transaction from a pool's governance for each of its validators. It is also the only path for validators with `0x00` withdrawal credentials, which [EIP-8365](./eip-8365.md) and [EIP-8433](./eip-8433.md) retire. The two paths have different powers. A BLS signature can be forged by an adversary with a CRQC, so the operator path is register-only: once keys are registered, a forged BLS signature cannot change them. Only the owner path, which does not depend on BLS, can replace keys. If BLS breaks before the switch, every key registered before the break is authentic, and any key registered through the operator path after the break can be overridden by the validator's owner. The owner path authorizes a 32-byte root on the execution layer and delivers the key and proof on the consensus layer. Storing the full registration in an [EIP-7002](./eip-7002.md)-style queue would take about 80 storage slots per request; with the commitment, each record is 100 bytes. ### One parameter set Each registry entry carries a version byte. Version `0` marks a validator with no registered keys, and version `1` is the single parameter set of this EIP: two-layer leanXMSS over Keccak-256. The version byte identifies the verifier of each entry, independently of the fork that registered it. Registering keys under several hash functions, so that the hash function could be chosen after registration, multiplies the registry size and the key generation work. This EIP fixes the hash function and the parameters instead. ### Hash function choice The hash function is Keccak-256, the hash function of the execution layer. It is the original submission underlying SHA-3 (FIPS 202), differing from SHA3-256 only in its padding. Keccak has been analyzed publicly since the SHA-3 competition, and no collision or preimage attack on full-round Keccak-256 is known. The security of the signature scheme rests on the hash function alone, so this EIP uses a hash function with a long cryptanalytic record that every execution client already implements. The cell encoding leaves inputs of at most 128 bytes unchanged. It changes only the leaf hash, whose 960-byte input spans several blocks, so that every block carries whole 16-byte cells for the leanVM verifier. Arithmetization-oriented hashes such as Poseidon are cheaper to prove under some conditions but have a shorter cryptanalytic record. Proving systems over binary fields, such as leanVM, prove standard hash functions such as Keccak-256, so the aggregation proofs do not require an arithmetization-oriented hash. ### A list parallel to the validator registry The keys are stored in a list parallel to `validators`, as [EIP-8321](./eip-8321.md) does for RANDAO commitments. The `Validator` container and its generalized indices do not change, so light clients and proofs against them are not affected until the switch. The fork that switches duties may move the keys into the validator record, since that fork changes the meaning of `Validator.pubkey`. ### Registration outside the deposit contract The deposit contract's event layout is fixed, and its events are converted into fixed-size `DepositRequest` records ([EIP-6110](./eip-6110.md)) with no field for post-quantum keys. Existing validators also need a registration path that does not involve a deposit. A new validator registers through the same path once its deposit has been processed and it has an index, which can be before its activation. Setting keys at deposit time requires a new deposit format, which is part of the fork that retires BLS deposits. ### Per-block limits A registration is 2541 bytes, dominated by the proof of possession. `MAX_PQ_KEY_REGISTRATIONS = 8` bounds the extra block size at about 20 KiB, and the verification cost at eight BLS verifications and 1,488 Keccak-f permutations per block. At that rate a million validators register in 125,000 full blocks, about 17 days of 12-second slots. The owner path is bounded by the request fee of the execution-layer contract, at a target of 2 authorizations per block and a maximum of 16, and by the expiry of pending authorizations. ## Backwards Compatibility This EIP requires a hard fork of the consensus and execution layers. It changes no existing duty: every message is still authenticated with BLS, and a validator that never registers keys is unaffected by this fork. The new execution-layer request type extends the request list committed in the block header ([EIP-7685](./eip-7685.md)), and the engine API carries it with the other execution requests. ## Test Cases Test vectors are to be provided in the consensus specifications and the Ethereum cryptography specifications, covering at least: - The cell encoding: inputs of 0, 128, 129, and 960 bytes. - leanXMSS verification: valid signatures at leaf indices `0`, `2**16 - 1`, `2**16`, and `2**32 - 1`, and rejection of a digest that is not a codeword and of a signature whose top layer certifies a different bottom root. - An operator-path registration for an unregistered validator, and rejection of a second operator-path registration for the same validator. - An owner-path registration that consumes a matching authorization, replaces registered keys, and is rejected once the authorization has expired or its root does not match. - Rejection of a key already registered by another validator, of an invalid proof of possession, and of a proof of possession at a leaf index other than `PQ_POSSESSION_XMSS_INDEX`. - Authorization requests ignored for unknown public keys, mismatched source addresses, non-execution withdrawal credentials, and exiting validators. A newer authorization replaces a pending one for the same validator. - The fork upgrade, and the invariant `len(state.pq_public_keys) == len(state.validators)` after new validators are added. ## Reference Implementation leanXMSS over Keccak-256 is specified and implemented in the leanVM repository, with a Lean 4 proof of its classical security. The consensus-layer changes and the request contract are to be provided before this EIP moves beyond Draft. ## Security Considerations ### Quantum adversaries during the registration window A registration through the operator path is only as authentic as the BLS signature that authorizes it. Before a CRQC exists, it is authentic. After a CRQC exists, an adversary can register keys of its choice for any validator that has not registered yet. Validators SHOULD therefore register early. Registrations made before a break cannot be changed through the operator path, and the owner path can override any registration made after it. The owner path is only as secure as the withdrawal address. An externally owned account secured by ECDSA is broken by the same adversary, who can then authorize keys of its choice. That adversary also controls the validator's stake, through withdrawals and exits, so the registry does not add to its power. Securing withdrawal addresses is the execution layer's part of the migration. ### Leaf reuse Two different messages signed at the same leaf index reveal enough chain values to forge further signatures at that index. The registry relies on three rules to prevent this: - Each leaf index signs at most one message. Under fixed allocation, slashing protection by slot and duty enforces this. Under free allocation, the signer's state does. Keystores and signer state SHOULD follow [EIP-8310](./eip-8310.md). - For leaf index `0`, the signer MUST sign only the registration it intends to submit. Signing is deterministic, so resubmitting the same registration is safe. - Each top key signs the root of its bottom tree and nothing else. Bottom trees MUST be derived deterministically from the seed, so that a regenerated bottom tree has the same root and a regenerated certification is byte-identical. Certifying two different roots under the same top key is a leaf reuse. ### Key copying and squatting The registry rejects a key that another validator has already registered. Without that rule, a validator could register another's key and claim its signatures as its own participation, since the signed messages do not name the validator. The proof of possession signs the validator index, so a copied key and its proof cannot be registered for a different index. A key that is published but not yet registered cannot be squatted. ### Readiness is not registration A registered key proves that its holder could sign once, at registration time. It does not prove that the validator will run post-quantum software, keep its seed, or keep its signer state consistent at the switch. The registered balance is an upper bound on readiness, and the switch SHOULD be scheduled with a margin above the two-thirds threshold that finality needs. ### Hash function and parameters The leanXMSS specification proves 127 bits of classical strong unforgeability in the random oracle model, in Lean 4, for the scheme as specified here. The parameters target 64 bits of quantum security, NIST level 1. That target is not a proved bound. ### Denial of service and state growth - **Blocks.** At most `MAX_PQ_KEY_REGISTRATIONS` registrations per block, about 20 KiB, eight BLS verifications and 1,488 Keccak-f permutations. - **Gossip.** Operator-path registrations are first-seen per validator. Owner-path registrations propagate only once a matching authorization, paid for on the execution layer, is in the head state. - **State.** Each registry entry is 33 bytes, about 33 MB for a million validators. The authorization queue holds at most one entry per validator, and each entry expires after `PQ_KEY_AUTHORIZATION_EXPIRY` epochs. ### Key management The leanXMSS seed SHOULD NOT be derived from the validator's BLS key or from the hash-chain seed of [EIP-8321](./eip-8321.md). Losing the signer state risks leaf reuse, even if the seed is kept. Keystores for leanXMSS keys SHOULD follow [EIP-8310](./eip-8310.md), which extends [ERC-2335](./eip-2335.md). ## Copyright Copyright and related rights waived via [CC0](../LICENSE.md).