Skip to main content

LCP Client

Overview

The LCP Client is an on-chain Light Client that verifies commitments indicating state transitions and existence proofs generated by the ELC.

It is intended to be implemented as a Light Client module in ibc-go or ibc-solidity, conforming to ICS-02. An instance of LCP Client must be created on the downstream chain for each upstream chain.

Desired Properties

Based on the Security Model of LCP, the LCP Client must satisfy the following properties:

  • The LCP Client correctly tracks the state of the upstream chain by verifying the UpdateStateProxyMessage generated by the ELC. For example, if a malicious LCP operator provides messages and its commitment based on a differently configured ELC, the LCP Client will reject them.

  • To mitigate the risk of SGX compromise, the EK obtained through Remote Attestation (RA) should have an expiration time based on the RA's timestamp, and a commitment and proof using an EK that has expired should be rejected.

State Definition

ClientState

The definition of ClientState is as follows:

message ClientState {
bytes mrenclave = 1;
uint64 key_expiration = 2;
bool frozen = 3;
Height latest_height = 4;
// e.g. SW_HARDENING_NEEDED, CONFIGURATION_AND_SW_HARDENING_NEEDED (except "OK")
repeated string allowed_quote_statuses = 5;
// e.g. INTEL-SA-XXXXX
repeated string allowed_advisory_ids = 6;
repeated bytes operators = 7;
uint64 operators_nonce = 8;
uint64 operators_threshold_numerator = 9;
uint64 operators_threshold_denominator = 10;
}
  • mrenclave indicates the MRENCLAVE of the LCP Enclave. For example, if the LCP Client is deployed on Ethereum and references a Tendermint chain, this MRENCLAVE must match the MRENCLAVE of the LCP Enclave that includes the Tendermint ELC.
  • key_expiration indicates the expiration time of the EK included in the AVR (in seconds based on the RA's timestamp).
  • frozen indicates whether this client is frozen.
  • latest_height indicates the latest height of the ELC state verified by this client. If it is 0, it indicates that the client is not initialized yet.
  • allowed_quote_statuses is a list of allowable quote statuses in the AVR.
  • allowed_advisory_ids is a list of allowable advisory IDs in the AVR.
  • (optional) operators is a list of addresses of LCP Operators corresponding to this client.
  • (optional) operators_nonce indicates the nonce of the LCP operators corresponding to this Client.
  • (optional) operators_threshold_numerator, operators_threshold_denominator define the threshold for the operators of the ELC corresponding to this ClientState.

Also, the list of EKs is stored in the AuxStorage defined as follows:

message AuxStorage {
// key: EKAddr
map<bytes, EKInfo> ekInfos = 1;
}

message EKInfo {
uint64 expired_at = 1;
bytes operator;
}
  • ekInfos is a map with EKAddr as the key and an EKInfo as the value, which indicates the expiration time of the EK and the operator who owns the EK. The operator is a 20-byte value; if it is 0, it indicates that the operator is not set.

ConsensusState

ConsensusState represents the state of the ELC at each height (i.e., consensus height). Its definition is as follows:

message ConsensusState {
bytes state_id = 1;
// unix timestamp in seconds
uint64 timestamp = 2;
}
  • state_id indicates the State ID of the ELC corresponding to the consensus height.
  • timestamp indicates the block timestamp of the upstream chain corresponding to the consensus height.

LCP Operator

An LCP Operator is an operator who runs an LCP Node. The operator holds the private keys and can set their public keys in the operators of the ClientState of the LCP Client. If the operators are set, operator signatures are required when verifying commitments and proofs with this client. The threshold for the number of valid signatures is determined by operators_threshold_numerator and operators_threshold_denominator. However, this is optional, and if the operators in the ClientState are empty, operator signatures are not required.

If operators is not empty, it is possible to add or remove operators and change the threshold through the UpdateOperators function.

Storage

In the functions, STORAGE indicates persistent storage for maintaining the ClientState, ConsensusState, and AuxStorage of the LCP Client. This storage must have the same property as Ethereum's storage and must be accessible only by the LCP Client.

STORAGE has the following structure:

struct ClientStorage {
ClientState clientState;
mapping(Height => ConsensusState) consensusStates;
AuxStorage aux;
}
mapping(string clientId => ClientStorage) STORAGE;

Functions

CreateClient

CreateClient is a function to create a new instance of the LCP Client by providing ClientState and ConsensusState. Typically, this function is called by a relayer via the IBC handler.

In ibc-solidity, the given states during CreateClient are verified using the following function:

function initializeClient(
string calldata clientId,
ClientState calldata clientState,
ConsensusState calldata consensusState
) public returns (Height memory height) {
require(clientState.latest_height.revision_number == 0 && clientState.latest_height.revision_height == 0);
require(!clientState.frozen);
require(clientState.key_expiration != 0);
require(clientState.mrenclave.length == 32);
require(clientState.operators_nonce == 0);
require(
clientState.operators.length == 0
|| (clientState.operators_threshold_numerator != 0 && clientState.operators_threshold_denominator != 0)
);
require(clientState.operators_threshold_numerator <= clientState.operators_threshold_denominator);
require(consensusState.timestamp == 0);
require(consensusState.state_id.length == 0);

// ensure the operators are sorted(ascending order) and unique
address prev;
for (uint256 i = 0; i < clientState.operators.length; i++) {
require(clientState.operators[i].length == 20, "invalid operator address length");
address addr = address(bytes20(clientState.operators[i]));
require(addr != address(0), "invalid operator address");
require(prev == address(0) || prev < addr, "invalid operator address order");
prev = addr;
}
STORAGE[clientId].clientState = clientState;
return clientState.latest_height;
}

RegisterEnclaveKey

RegisterEnclaveKey is a function to register the Enclave Key (EK) generated by LCP to the client. Multiple EKs can be registered per LCP Client.

This function is defined as follows:

struct RegisterEnclaveKeyMessage {
bytes report;
bytes signature;
bytes signing_cert;
// optional
bytes operator_signature;
}

function registerEnclaveKey(string calldata clientId, RegisterEnclaveKeyMessage calldata message)
public
returns (Height[] memory heights)
{
require(verifyAVR(
message.report, message.signature, message.signing_cert,
STORAGE[clientId].clientState.allowed_quote_statuses,
STORAGE[clientId].clientState.allowed_advisory_ids
), "invalid AVR");
(bytes32 mrenclave, uint64 attestationTime, address enclaveKey, address operator) = extractAVRInfo(message.report);

require(bytes32(STORAGE[clientId].clientState.mrenclave) == mrenclave, "unexpected mrenclave");

// if `operator_signature` is empty, the operator address is zero
address signer;
if (message.operator_signature.length != 0) {
signer = verifyECDSASignature(
keccak256(LCPOperator.computeEIP712RegisterEnclaveKey(message.report)), message.operator_signature
);
}
require(operator == address(0) || operator == signer, "unexpected operator");
uint64 expiredAt = attestationTime + STORAGE[clientId].clientState.key_expiration;
require(expiredAt > block.timestamp, "AVR already expired");
EKInfo storage ekInfo = STORAGE[clientId].aux.ekInfos[enclaveKey];
if (ekInfo.expiredAt != 0) {
require(ekInfo.operator == operator, "unexpected operator");
require(ekInfo.expiredAt == expiredAt, "unexpected expiredAt");
// NOTE: if the key already exists, don't update any state
return heights;
}
ekInfo.expiredAt = expiredAt;
ekInfo.operator = operator;

emit RegisteredEnclaveKey(clientId, enclaveKey, expiredAt, operator);

// Note: client and consensus state are not always updated in registerEnclaveKey
return heights;
}

The registerEnclaveKey function requires a Verifiable Quote(i.e., AVR) obtained from the Attestation Service as parameter, performs verification, and if successful, sets the EKAddr included in the report data of the AVR's Quote into ekInfos along with its expiration time and operator address.

The verification flow in the case of using IAS is as follows:

  1. Verify Intel's signature on the AVR obtained from IAS.
  2. Confirm that the values of isvEnclaveQuoteStatus and advisoryID included in the AVR are allowable values specified in the ClientState.
  3. Confirm that the MRENCLAVE of the Quote included in the AVR matches the MRENCLAVE of the ClientState.
  4. Confirm that the sum of the AVR's timestamp and the key_expiration of the ClientState is greater than the current time.
  5. If an operator address is included in the report data of the AVR's Quote, perform the following verification; if not included, set 0 in the operator field of ekInfos: 5-1. Confirm that a valid operator_signature over report with the corresponding operator address is included. 5-2. Set the operator address in ekInfos.

UpdateClient

UpdateClient is a function that updates the ClientState and ConsensusState corresponding to the specified Client ID of the LCP Client based on a message indicating the state transition of the ELC. This function is called by a relayer via the IBC handler.

This function is defined as follows:

struct UpdateClientMessage {
// abi.encode(HeaderedProxyMessage)
bytes proxy_message;
bytes[] signatures;
}

struct HeaderedProxyMessage {
// LCP_MESSAGE_HEADER_UPDATE_STATE: bytes32(uint256(LCP_MESSAGE_VERSION) << 240 | uint256(LCP_MESSAGE_TYPE_UPDATE_STATE) << 224)
// LCP_MESSAGE_HEADER_MISBEHAVIOUR: bytes32(uint256(LCP_MESSAGE_VERSION) << 240 | uint256(LCP_MESSAGE_TYPE_MISBEHAVIOUR) << 224)
bytes32 header;
bytes message;
}

function updateClient(string calldata clientId, UpdateClientMessage.Data calldata message)
public
returns (Height.Data[] memory heights)
{
verifySignatures(clientId, keccak256(message.proxy_message), message.signatures);

HeaderedProxyMessage memory hm =
abi.decode(message.proxy_message, (HeaderedProxyMessage));
if (hm.header == LCP_MESSAGE_HEADER_UPDATE_STATE) {
return updateState(clientId, abi.decode(hm.message, (UpdateStateProxyMessage)));
} else if (hm.header == LCP_MESSAGE_HEADER_MISBEHAVIOUR) {
return submitMisbehaviour(clientId, abi.decode(hm.message, (MisbehaviourProxyMessage)));
} else {
revert("unexpected header");
}
}

This function verifies that signatures are valid for the proxy_message, and then calls different functions depending on the decoded Header. Currently, the following two types of messages are supported:

  1. UpdateStateProxyMessage: Indicates a state transition of the ELC corresponding to two heights.
  2. MisbehaviourProxyMessage: Indicates misbehavior of the chain detected by the ELC.

Verification of the signature by the EK is performed in the following verifySignatures function.

function verifySignatures(string calldata clientId, bytes32 commitment, bytes[] memory signatures)
internal
view
{
uint256 opNum = STORAGE[clientId].clientState.operators.length;
if (opNum == 0) {
require(signatures.length == 1, "invalid signatures length");
ensureActiveKey(clientId, verifyECDSASignature(commitment, signatures[0]), address(0));
} else {
require(signatures.length == opNum, "invalid signatures length");
uint256 success = 0;
for (uint256 i = 0; i < signatures.length; i++) {
if (signatures[i].length != 0) {
ensureActiveKey(
clientId,
verifyECDSASignature(commitment, signatures[i]),
address(bytes20(STORAGE[clientId].clientState.operators[i]))
);
success++;
}
}
require(success * STORAGE[clientId].clientState.operators_threshold_denominator >= STORAGE[clientId].clientState.operators_threshold_numerator * opNum, "insufficient signatures");
}
}

function ensureActiveKey(string calldata clientId, address ekAddr, address opAddr) internal view {
EKInfo storage ekInfo = STORAGE[clientId].ekInfos[ekAddr];
uint256 expiredAt = ekInfo.expiredAt;
require(expiredAt != 0, "enclave key not exist");
require(expiredAt > block.timestamp, "enclave key expired");
require(opAddr == address(0) || ekInfo.operator == opAddr, "unexpected operator");
}

This function verifies the signatures over the message as follows:

  • If the operators in the ClientState is empty, it verifies only one signature and confirms that the EK is active.
  • If the operators in the ClientState is not empty, it performs the following verification:
    • Confirms that the number of operators in the ClientState matches the number of signatures.
    • For each signature, if it is not empty, it verifies that the signature is valid, that the EK is active, and for signature at index i, that ekInfos[i].operator matches operators[i].
    • Confirms that the EK corresponds to the operator.
    • Confirms that the number of signatures that passed the above verification exceeds the threshold.

UpdateState

This function performs a state transition of the LCP Client corresponding to the specified Client ID based on the UpdateStateProxyMessage generated by the ELC. This function is called by the UpdateClient function.

The function is defined as follows:

struct UpdateStateProxyMessage {
Height prevHeight;
bytes32 prevStateId;
Height postHeight;
bytes32 postStateId;
uint128 timestamp;
bytes context;
EmittedState[] emittedStates;
}

function updateState(string calldata clientId, UpdateStateProxyMessage memory pmsg)
internal
returns (Height[] memory heights)
{
ClientState storage clientState = STORAGE[clientId].clientState;
require(!clientState.frozen, "clientState must not be frozen");

// if the `latest_height` is zero, the client is not initialized
if (clientState.latest_height.revision_number == 0 && clientState.latest_height.revision_height == 0) {
// if the client is not initialized, `emittedStates` must not be empty
// This is because the initial state must be always available for the LCP recovery process: ../06-security-model.md#state-recovery-protocol
require(pmsg.emittedStates.length > 0, "emittedStates must not be empty");
} else {
require(pmsg.prevStateId != bytes32(0), "prevStateId must not be empty");
require(STORAGE[clientId].consensusStates[pmsg.prevHeight.toUint128()].stateId == pmsg.prevStateId, "unexpected prevStateId");
}

// check if the `UpdateStateCommitment` is valid with the chain's latest block timestamp
validateContext(pmsg.context, block.timestamp * 1e9);

uint128 latestHeight = clientState.latest_height.toUint128();
uint128 postHeight = pmsg.postHeight.toUint128();
if (latestHeight < postHeight) {
clientState.latest_height = pmsg.postHeight;
}

ConsensusState storage consensusState = STORAGE[clientId].consensusStates[postHeight];
consensusState.stateId = pmsg.postStateId;
consensusState.timestamp = uint64(pmsg.timestamp);

return [pmsg.postHeight];
}

This function performs the following verifications:

  • The ClientState corresponding to clientId is not frozen.
  • If latest_height is 0, emittedStates is not empty.
    • When latest_height is 0, it indicates that the initialization of the ClientState is not complete. The commitment of the first updateState must include emittedStates. This is for the State Recovery Protocol of LCP.
  • If latest_height is not 0, prev_state_id is not empty.
    • The prev_state_id matches the state_id of the ConsensusState corresponding to prev_height.
  • The Validation Context in context is valid based on the latest block timestamp of the chain.

If the verification succeeds, and post_height is greater than latest_height, it updates the latest_height of the ClientState to post_height and creates a ConsensusState based on post_state_id and timestamp with post_height as the consensus height.

SubmitMisbehaviour

This function verifies the MisbehaviourProxyMessage and freezes the ClientState. This function is called by the UpdateClient function.

struct MisbehaviourProxyMessage {
PrevState[] prevStates;
bytes context;
bytes clientMessage;
}

struct PrevState {
Height height;
bytes32 stateId;
}

function submitMisbehaviour(string calldata clientId, MisbehaviourProxyMessage memory pmsg)
internal
returns (Height memory heights)
{
require(!STORAGE[clientId].clientState.frozen, "clientState must not be frozen");
require(pmsg.prevStates.length != 0, "prevStates must not be empty");
for (uint256 i = 0; i < pmsg.prevStates.length; i++) {
PrevState memory prev = pmsg.prevStates[i];
require(prev.stateId != bytes32(0), "prevStateId must not be empty");
require(STORAGE[clientId].consensusStates[prev.height].stateId == prev.stateId, "unexpected prevStateId");
}
validateContext(pmsg.context, block.timestamp * 1e9);
STORAGE[clientId].clientState.frozen = true;
return heights;
}

This function performs the following verifications:

  • The ClientState corresponding to clientId is not frozen.
  • prevStates is not empty.
  • For each element prev_state in prevStates, a ConsensusState corresponding to prev_state.height exists, and its stateId matches prev_state.stateId.

If the verification succeeds, it updates the frozen status of the ClientState to true.

VerifyMembership

The verifyMembership and verifyNonMembership functions verify the VerifyMembershipCommitment of the ELC based on the ConsensusState held by the client.

struct CommitmentProofs {
/// @dev message is `VerifyMembershipProxyMessage` or `VerifyNonMembershipProxyMessage` serialized to bytes
bytes message;
/// @dev signatures are the EK signatures
bytes[] signatures;
}

function verifyMembership(
string calldata clientId,
Height.Data calldata height,
uint64,
uint64,
bytes calldata proof,
bytes calldata prefix,
bytes calldata path,
bytes calldata value
) public view returns (bool) {
(
CommitmentProofs memory commitmentProofs,
VerifyMembershipProxyMessage memory message
) = decode(proof);
validateProxyMessage(STORAGE[clientId], message, height, prefix, path);
require(keccak256(value) == message.value, "invalid value");
verifySignatures(clientId, keccak256(commitmentProofs.message), commitmentProofs.signatures);
return true;
}

function verifyNonMembership(
string calldata clientId,
Height.Data calldata height,
uint64,
uint64,
bytes calldata proof,
bytes calldata prefix,
bytes calldata path
) public view returns (bool) {
(
CommitmentProofs memory commitmentProofs,
VerifyMembershipProxyMessage memory message
) = decode(proof);
validateProxyMessage(STORAGE[clientId], message, height, prefix, path);
require(message.value.length == 0, "invalid value");
verifySignatures(clientId, keccak256(commitmentProofs.message), commitmentProofs.signatures);
return true;
}

function validateProxyMessage(
string calldata clientId,
VerifyMembershipProxyMessage memory message,
Height calldata height,
bytes calldata prefix,
bytes calldata path
) internal view {
ConsensusState storage consensusState = clientStorage.consensusStates[message.height];
require(consensusState.stateId != bytes32(0), "consensus state not found");
require(height == message.height, "invalid height");
require(keccak256(prefix) == keccak256(message.prefix), "invalid prefix");
require(keccak256(path) == keccak256(message.path), "invalid path");
require(consensusState.stateId == message.stateId, "invalid stateId");
}

These functions perform the following verifications:

  • message.prefix matches the given prefix.
  • message.path matches the given path.
  • A ConsensusState corresponding to message.height exists, and its stateId matches message.state_id.
  • In the case of verifyMembership, message.value matches the value to be verified.
  • In the case of verifyNonMembership, message.value is empty.

Note that it must be verified by the caller that the ClientState corresponding to clientId is not frozen.

UpdateOperators

The UpdateOperators function adds or removes operators and changes the threshold of the Client's operators corresponding to the ClientID.

This function fails if operators is empty during initialization by the CreateClient function.

struct UpdateOperatorsMessage {
uint64 nonce;
bytes[] new_operators;
uint64 new_operators_threshold_numerator;
uint64 new_operators_threshold_denominator;
bytes[] signatures;
}

function updateOperators(string calldata clientId, UpdateOperatorsMessage calldata message)
public
returns (Height[] memory heights)
{
ClientState.Data storage clientState = STORAGE[clientId].clientState;
require(clientState.operators.length > 0, "permissionless client cannot update operators");
require(message.signatures.length == clientState.operators.length, "invalid signatures length");
require(
message.new_operators_threshold_numerator != 0 && message.new_operators_threshold_denominator != 0,
"invalid operators threshold"
);
uint64 nextNonce = clientState.operators_nonce + 1;
require(message.nonce == nextNonce, "unexpected operators nonce");
bytes32 commitment = keccak256(
computeEIP712UpdateOperators(
clientId,
nextNonce,
message.new_operators,
message.new_operators_threshold_numerator,
message.new_operators_threshold_denominator
)
);
uint256 success = 0;
for (uint256 i = 0; i < message.signatures.length; i++) {
if (message.signatures[i].length > 0) {
address operator = verifyECDSASignature(commitment, message.signatures[i]);
require(operator == address(bytes20(clientState.operators[i])), "unexpected operator");
success++;
}
}
require(success * STORAGE[clientId].clientState.operators_threshold_denominator >=
STORAGE[clientId].clientState.operators_threshold_numerator * clientState.operators.length,
"insufficient signatures");
// delete the previous operators
delete clientState.operators;
// ensure the new operators are sorted(ascending order) and unique
for (uint256 i = 0; i < message.new_operators.length; i++) {
if (i > 0) {
require(message.new_operators[i - 1] < message.new_operators[i], "invalid operator address order");
}
clientState.operators.push(message.new_operators[i]);
}
clientState.operators_nonce = nextNonce;
clientState.operators_threshold_numerator = message.new_operators_threshold_numerator;
clientState.operators_threshold_denominator = message.new_operators_threshold_denominator;
return heights;
}

Communication Flow

The communication flow between the LCP Client and other related components is shown below.

Initialization

LCP Client Initialization

Register EK

LCP Client Register EK

Packet-Relay Flow

LCP Client Packet-Relay Flow