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 withEKAddr
as the key and anEKInfo
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:
- Verify Intel's signature on the AVR obtained from IAS.
- Confirm that the values of
isvEnclaveQuoteStatus
andadvisoryID
included in the AVR are allowable values specified in the ClientState. - Confirm that the MRENCLAVE of the Quote included in the AVR matches the MRENCLAVE of the ClientState.
- Confirm that the sum of the AVR's timestamp and the
key_expiration
of the ClientState is greater than the current time. - 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 validoperator_signature
overreport
with the corresponding operator address is included. 5-2. Set the operator address inekInfos
.
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:
UpdateStateProxyMessage
: Indicates a state transition of the ELC corresponding to two heights.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 ofsignatures
. - 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
, thatekInfos[i].operator
matchesoperators[i]
. - Confirms that the EK corresponds to the operator.
- Confirms that the number of signatures that passed the above verification exceeds the threshold.
- Confirms that the number of
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 toclientId
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 firstupdateState
must includeemittedStates
. This is for the State Recovery Protocol of LCP.
- When
- If
latest_height
is not 0,prev_state_id
is not empty.- The
prev_state_id
matches thestate_id
of theConsensusState
corresponding toprev_height
.
- The
- 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 toclientId
is not frozen. prevStates
is not empty.- For each element
prev_state
inprevStates
, aConsensusState
corresponding toprev_state.height
exists, and itsstateId
matchesprev_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 givenprefix
.message.path
matches the givenpath
.- A
ConsensusState
corresponding tomessage.height
exists, and itsstateId
matchesmessage.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.