diff --git a/.changelog/4922.feature.md b/.changelog/4922.feature.md new file mode 100644 index 00000000000..e209f6cc384 --- /dev/null +++ b/.changelog/4922.feature.md @@ -0,0 +1 @@ +Add client node TEE freshness verification diff --git a/go/runtime/host/protocol/types.go b/go/runtime/host/protocol/types.go index e3b279a7984..c7578d16b64 100644 --- a/go/runtime/host/protocol/types.go +++ b/go/runtime/host/protocol/types.go @@ -4,6 +4,8 @@ import ( "fmt" "reflect" + "github.com/tendermint/tendermint/crypto/merkle" + beacon "github.com/oasisprotocol/oasis-core/go/beacon/api" "github.com/oasisprotocol/oasis-core/go/common" "github.com/oasisprotocol/oasis-core/go/common/cbor" @@ -14,6 +16,7 @@ import ( "github.com/oasisprotocol/oasis-core/go/common/sgx/quote" "github.com/oasisprotocol/oasis-core/go/common/version" consensus "github.com/oasisprotocol/oasis-core/go/consensus/api" + consensusTx "github.com/oasisprotocol/oasis-core/go/consensus/api/transaction" roothash "github.com/oasisprotocol/oasis-core/go/roothash/api" "github.com/oasisprotocol/oasis-core/go/roothash/api/block" "github.com/oasisprotocol/oasis-core/go/roothash/api/commitment" @@ -109,6 +112,8 @@ type Body struct { HostFetchTxBatchResponse *HostFetchTxBatchResponse `json:",omitempty"` HostFetchGenesisHeightRequest *HostFetchGenesisHeightRequest `json:",omitempty"` HostFetchGenesisHeightResponse *HostFetchGenesisHeightResponse `json:",omitempty"` + HostProveFreshnessRequest *HostProveFreshnessRequest `json:",omitempty"` + HostProveFreshnessResponse *HostProveFreshnessResponse `json:",omitempty"` } // Type returns the message type by determining the name of the first non-nil member. @@ -503,3 +508,18 @@ type HostFetchTxBatchResponse struct { // Batch is a batch of transactions. Batch [][]byte `json:"batch,omitempty"` } + +// HostProveFreshnessRequest is a request to host to prove state freshness. +type HostProveFreshnessRequest struct { + Blob [32]byte `json:"blob"` +} + +// HostProveFreshnessResponse is a response from host proving state freshness. +type HostProveFreshnessResponse struct { + // Signed prove freshness transaction. + SignedTx *consensusTx.SignedTransaction `json:"signed_tx"` + // Height at which the transaction was published. + Height uint64 `json:"height"` + // Merkle proof of inclusion. + Proof *merkle.Proof `json:"proof"` +} diff --git a/go/runtime/registry/handlers.go b/go/runtime/registry/handlers.go index 0517cdf0b13..7c3e1a61811 100644 --- a/go/runtime/registry/handlers.go +++ b/go/runtime/registry/handlers.go @@ -2,8 +2,15 @@ package registry import ( "context" + "crypto/sha256" + "fmt" + "reflect" + + "github.com/tendermint/tendermint/crypto/merkle" "github.com/oasisprotocol/oasis-core/go/common/cbor" + consensus "github.com/oasisprotocol/oasis-core/go/consensus/api" + registry "github.com/oasisprotocol/oasis-core/go/registry/api" "github.com/oasisprotocol/oasis-core/go/runtime/host/protocol" runtimeKeymanager "github.com/oasisprotocol/oasis-core/go/runtime/keymanager/api" storage "github.com/oasisprotocol/oasis-core/go/storage/api" @@ -113,3 +120,70 @@ func handlerHostFetchTxBatchRequest(ctx context.Context, h *runtimeHostHandler, Batch: raw, }, nil } + +func handlerHostProveFreshnessRequest(ctx context.Context, h *runtimeHostHandler, req *protocol.HostProveFreshnessRequest) (*protocol.HostProveFreshnessResponse, error) { + // Subscribe before submit as we don't want to miss the block with the transaction. + blockCh, blockSub, err := h.consensus.WatchBlocks(ctx) + if err != nil { + return nil, err + } + defer blockSub.Close() + + // Prepare, sign and submit prove freshness transaction. + identity, err := h.env.GetIdentity(ctx) + if err != nil { + return nil, err + } + tx := registry.NewProveFreshnessTx(0, nil, req.Blob) + sigTx, err := consensus.SignAndSubmitTx(ctx, h.consensus, identity.NodeSigner, tx) + if err != nil { + return nil, err + } + cborTx := cbor.Marshal(sigTx) + + // Once transaction is included in a block, construct Merkle proof of inclusion and send it + // as a response back to the runtime. +loop: + for { + select { + case block, ok := <-blockCh: + if !ok { + break loop + } + + // Check inclusion. + txs, err := h.consensus.GetTransactions(ctx, block.Height) + if err != nil { + return nil, err + } + idx := -1 + for i, tx := range txs { + if reflect.DeepEqual(tx, cborTx) { + idx = i + break + } + } + if idx < 0 { + continue + } + + // Merkle tree is computed over hashes and not over transactions. + hashes := make([][]byte, 0, len(txs)) + for _, tx := range txs { + hash := sha256.Sum256(tx) + hashes = append(hashes, hash[:]) + } + _, proofs := merkle.ProofsFromByteSlices(hashes) + + return &protocol.HostProveFreshnessResponse{ + SignedTx: sigTx, + Height: uint64(block.Height), + Proof: proofs[idx], + }, nil + case <-ctx.Done(): + break loop + } + } + + return nil, fmt.Errorf("failed to prove freshness: ProveFreshness transaction not published in a block") +} diff --git a/go/runtime/registry/host.go b/go/runtime/registry/host.go index 3ac29a518b0..86dc6fa82f8 100644 --- a/go/runtime/registry/host.go +++ b/go/runtime/registry/host.go @@ -10,6 +10,7 @@ import ( "github.com/eapache/channels" "github.com/oasisprotocol/oasis-core/go/common/cbor" + "github.com/oasisprotocol/oasis-core/go/common/identity" "github.com/oasisprotocol/oasis-core/go/common/logging" "github.com/oasisprotocol/oasis-core/go/common/version" consensus "github.com/oasisprotocol/oasis-core/go/consensus/api" @@ -150,6 +151,9 @@ type RuntimeHostHandlerEnvironment interface { // GetTxPool returns the transaction pool for this runtime. GetTxPool(ctx context.Context) (txpool.TransactionPool, error) + + // GetIdentity returns the identity of a node running this runtime. + GetIdentity(ctx context.Context) (*identity.Identity, error) } // RuntimeHostHandler is a runtime host handler suitable for compute runtimes. It provides the @@ -182,6 +186,8 @@ func (h *runtimeHostHandler) Handle(ctx context.Context, body *protocol.Body) (* res.HostFetchGenesisHeightResponse, err = handlerHostFetchGenesisHeightRequest(ctx, h, body.HostFetchGenesisHeightRequest) case body.HostFetchTxBatchRequest != nil: res.HostFetchTxBatchResponse, err = handlerHostFetchTxBatchRequest(ctx, h, body.HostFetchTxBatchRequest) + case body.HostProveFreshnessRequest != nil: + res.HostProveFreshnessResponse, err = handlerHostProveFreshnessRequest(ctx, h, body.HostProveFreshnessRequest) default: err = errMethodNotSupported } diff --git a/go/worker/common/committee/runtime_host.go b/go/worker/common/committee/runtime_host.go index 0d96a5da68c..25210acb4c4 100644 --- a/go/worker/common/committee/runtime_host.go +++ b/go/worker/common/committee/runtime_host.go @@ -3,6 +3,7 @@ package committee import ( "context" + "github.com/oasisprotocol/oasis-core/go/common/identity" "github.com/oasisprotocol/oasis-core/go/roothash/api/block" "github.com/oasisprotocol/oasis-core/go/runtime/host" "github.com/oasisprotocol/oasis-core/go/runtime/host/protocol" @@ -44,6 +45,11 @@ func (env *nodeEnvironment) GetTxPool(ctx context.Context) (txpool.TransactionPo return env.n.TxPool, nil } +// GetIdentity implements RuntimeHostHandlerEnvironment. +func (env *nodeEnvironment) GetIdentity(ctx context.Context) (*identity.Identity, error) { + return env.n.Identity, nil +} + // NewRuntimeHostHandler implements RuntimeHostHandlerFactory. func (n *Node) NewRuntimeHostHandler() protocol.Handler { return runtimeRegistry.NewRuntimeHostHandler(&nodeEnvironment{n}, n.Runtime, n.Consensus) diff --git a/runtime/src/common/crypto/signature.rs b/runtime/src/common/crypto/signature.rs index e62f481f3ad..12dde4c7ca4 100644 --- a/runtime/src/common/crypto/signature.rs +++ b/runtime/src/common/crypto/signature.rs @@ -207,6 +207,19 @@ pub struct SignatureBundle { pub signature: Signature, } +impl SignatureBundle { + /// Verify returns true iff the signature is valid over the given context + /// and message. + pub fn verify(&self, context: &[u8], message: &[u8]) -> bool { + let pk = match self.public_key { + Some(pk) => pk, + None => return false, + }; + + self.signature.verify(&pk, context, message).is_ok() + } +} + /// A abstract signer. pub trait Signer: Send + Sync { /// Generates a signature over the context and message. diff --git a/runtime/src/consensus/tendermint/verifier.rs b/runtime/src/consensus/tendermint/verifier.rs index f0d59a94fb9..004c4dbb949 100644 --- a/runtime/src/consensus/tendermint/verifier.rs +++ b/runtime/src/consensus/tendermint/verifier.rs @@ -10,10 +10,13 @@ use std::{ use anyhow::anyhow; use crossbeam::channel; use io_context::Context; +use rand::{rngs::OsRng, Rng}; use sgx_isa::Keypolicy; +use sha2::{Digest, Sha256}; use slog::{error, info}; use tendermint::{ block::{CommitSig, Height}, + merkle::HASH_SIZE, vote::{SignedVote, ValidatorIndex, Vote}, }; use tendermint_light_client::{ @@ -54,6 +57,7 @@ use crate::{ tendermint::{ decode_light_block, state_root_from_header, LightBlockMeta, TENDERMINT_CONTEXT, }, + transaction::{SignedTransaction, Transaction, SIGNATURE_CONTEXT}, verifier::{self, verify_state_freshness, Error, TrustRoot, TrustedState}, LightBlock, HEIGHT_LATEST, }, @@ -62,7 +66,7 @@ use crate::{ types::Body, }; -use super::{encode_light_block, store::LruStore}; +use super::{encode_light_block, merkle::Proof, store::LruStore}; /// Maximum number of times to retry initialization. const MAX_INITIALIZATION_RETRIES: usize = 3; @@ -75,6 +79,11 @@ const TRUSTED_STATE_STORAGE_KEY_PREFIX: &str = "tendermint.verifier.trusted_stat const TRUSTED_STATE_CONTEXT: &[u8] = b"oasis-core/verifier: trusted state"; /// Trusted state save interval (in consensus blocks). const TRUSTED_STATE_SAVE_INTERVAL: u64 = 128; +/// Size of nonce for prove freshness request. +const NONCE_SIZE: usize = 32; + +/// Nonce for prove freshness request. +type Nonce = [u8; NONCE_SIZE]; /// A verifier which performs no verification. pub struct NopVerifier { @@ -334,7 +343,8 @@ impl Verifier { Ok(untrusted_block) } - fn verify_freshness( + /// Verify state freshness using RAK and nonces. + fn verify_freshness_with_rak( &self, state: &ConsensusState, node_id: &Option, @@ -354,6 +364,90 @@ impl Verifier { ) } + /// Verify state freshness using prove freshness transaction. + /// + /// Verification is done in three steps. In the first one, the verifier selects a unique nonce + /// and sends it to the host. The second step is done by the host, who prepares, signs and + /// submits a prove freshness transaction using the received nonce. Once transaction is included + /// in a block, the host replies with block's height, transaction details and a Merkle proof + /// that the transaction was included in the block. In the final step, the verifier verifies + /// the proof and accepts state as fresh iff verification succeeds. + fn verify_freshness_with_proof(&self, instance: &mut Instance) -> Result<(), Error> { + info!( + self.logger, + "Verifying state freshness using prove freshness transaction" + ); + + // Generate a random nonce for prove freshness transaction. + let mut rng = OsRng {}; + let mut nonce = [0u8; NONCE_SIZE]; + rng.fill(&mut nonce); + + // Ask host for freshness proof. + let io = Io::new(&self.protocol); + let (height, proof, signed_tx) = io.fetch_freshness_proof(&nonce).map_err(|err| { + Error::FreshnessVerificationFailed(anyhow!("failed to fetch freshness proof: {}", err)) + })?; + + // Peek into the transaction to verify the nonce and the signature. No need to verify + // the name of the method though. + let tx: Transaction = cbor::from_slice(signed_tx.blob.as_slice()).map_err(|err| { + Error::FreshnessVerificationFailed(anyhow!( + "failed to unmarshal a prove freshness transaction: {}", + err + )) + })?; + match nonce[..].cmp(&tx.body[..]) { + std::cmp::Ordering::Equal => (), + _ => return Err(Error::FreshnessVerificationFailed(anyhow!("invalid nonce"))), + } + + let chain_context = self.protocol.get_host_info().consensus_chain_context; + let mut context = SIGNATURE_CONTEXT.to_vec(); + context.extend(chain_context.as_bytes()); + if !signed_tx.signature.verify(&context, &signed_tx.blob) { + return Err(Error::FreshnessVerificationFailed(anyhow!( + "failed to verify the signature" + ))); + } + + // Fetch the block in which the transaction was published. + let block = instance + .light_client + .verify_to_target(height.try_into().unwrap(), &mut instance.state) + .map_err(|err| { + Error::FreshnessVerificationFailed(anyhow!("failed to fetch the block: {}", err)) + })?; + + let header = block.signed_header.header; + if header.height.value() != height { + return Err(Error::VerificationFailed(anyhow!("invalid block"))); + } + + // Compute hash of the transaction and verify the proof. + let digest = Sha256::digest(&cbor::to_vec(signed_tx)); + let mut tx_hash = [0u8; HASH_SIZE]; + tx_hash.copy_from_slice(&digest); + + let root_hash = header + .data_hash + .ok_or_else(|| Error::FreshnessVerificationFailed(anyhow!("root hash not found")))?; + let root_hash = match root_hash { + TMHash::Sha256(hash) => hash, + TMHash::None => { + return Err(Error::FreshnessVerificationFailed(anyhow!( + "root hash not found" + ))); + } + }; + + proof.verify(root_hash, tx_hash).map_err(|err| { + Error::FreshnessVerificationFailed(anyhow!("failed to verify the proof: {}", err)) + })?; + + Ok(()) + } + fn verify( &self, cache: &mut Cache, @@ -451,7 +545,7 @@ impl Verifier { // Verify our own RAK is published in registry once per epoch. // This ensures consensus state is recent enough. if cache.last_verified_epoch != epoch { - cache.node_id = self.verify_freshness(&state, &cache.node_id)?; + cache.node_id = self.verify_freshness_with_rak(&state, &cache.node_id)?; } // Cache verified runtime header. @@ -801,6 +895,13 @@ impl Verifier { "latest_height" => cache.latest_known_height(), ); + // Verify state freshness using the host. This step is required only for clients + // as executors and key managers verify freshness regularly using node registration + // (RAK with random nonces). + if self.protocol.get_rak().is_none() { + self.verify_freshness_with_proof(&mut instance)?; + }; + // Start the command processing loop. loop { let command = self.command_receiver.recv().map_err(|_| Error::Internal)?; @@ -1138,6 +1239,33 @@ impl Io { Ok(height) } + + fn fetch_freshness_proof( + &self, + nonce: &Nonce, + ) -> Result<(u64, Proof, SignedTransaction), IoError> { + let result = self + .protocol + .call_host( + Context::background(), + Body::HostProveFreshnessRequest { + blob: nonce.to_vec(), + }, + ) + .map_err(|err| IoError::rpc(RpcError::server(err.to_string())))?; + + // Extract proof from response. + let (height, proof, signed_tx) = match result { + Body::HostProveFreshnessResponse { + height, + proof, + signed_tx, + } => (height, proof, signed_tx), + _ => return Err(IoError::rpc(RpcError::server("bad response".to_string()))), + }; + + Ok((height, proof, signed_tx)) + } } impl components::io::Io for Io { diff --git a/runtime/src/consensus/verifier.rs b/runtime/src/consensus/verifier.rs index 12110e75efa..7cdc629cd00 100644 --- a/runtime/src/consensus/verifier.rs +++ b/runtime/src/consensus/verifier.rs @@ -29,6 +29,9 @@ pub enum Error { #[error("consensus chain context transition failed: {0}")] ChainContextTransitionFailed(#[source] anyhow::Error), + #[error("freshness verification: {0}")] + FreshnessVerificationFailed(#[source] anyhow::Error), + #[error("internal consensus verifier error")] Internal, } @@ -40,7 +43,8 @@ impl Error { Error::VerificationFailed(_) => 2, Error::TrustedStateLoadingFailed => 3, Error::ChainContextTransitionFailed(_) => 4, - Error::Internal => 5, + Error::FreshnessVerificationFailed(_) => 5, + Error::Internal => 6, } } } diff --git a/runtime/src/types.rs b/runtime/src/types.rs index 3c15af55573..068c3bf668c 100644 --- a/runtime/src/types.rs +++ b/runtime/src/types.rs @@ -16,6 +16,8 @@ use crate::{ consensus::{ beacon::EpochTime, roothash::{self, Block, ComputeResultsHeader, Header}, + tendermint::merkle::Proof, + transaction::SignedTransaction, LightBlock, }, enclave_rpc, @@ -242,6 +244,14 @@ pub enum Body { HostFetchGenesisHeightResponse { height: u64, }, + HostProveFreshnessRequest { + blob: Vec, + }, + HostProveFreshnessResponse { + signed_tx: SignedTransaction, + height: u64, + proof: Proof, + }, } impl Default for Body {