diff --git a/Cargo.lock b/Cargo.lock index 40ceaea053..b8dc262b70 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3293,6 +3293,7 @@ name = "miden-node-proto" version = "0.16.0-alpha.2" dependencies = [ "anyhow", + "assert_matches", "build-rs", "codegen", "fs-err", @@ -3307,6 +3308,7 @@ dependencies = [ "proptest", "prost", "prost-types", + "rand 0.10.2", "thiserror 2.0.18", "tonic", "tonic-prost", diff --git a/bin/benchmark/src/main.rs b/bin/benchmark/src/main.rs index 158088985d..39ab6d1865 100644 --- a/bin/benchmark/src/main.rs +++ b/bin/benchmark/src/main.rs @@ -12,6 +12,7 @@ use anyhow::{Context, Result}; use clap::{Parser, Subcommand}; use miden_node_proto::clients::{Builder, RpcClient}; use miden_node_proto::generated::rpc::BlockHeaderByNumberRequest; +use miden_protocol::Word; use miden_protocol::block::{BlockHeader, BlockNumber}; use miden_protocol::utils::serde::{Deserializable, Serializable}; use url::Url; @@ -143,7 +144,7 @@ async fn build_rpc_client( /// Discover the genesis commitment (hex) of the node at `rpc_url`. This is the value write RPCs /// such as `SubmitProvenTransaction` expect echoed back in request metadata. -async fn discover_genesis(rpc_url: &Url, timeout: Duration) -> Result { +async fn discover_genesis(rpc_url: &Url, timeout: Duration) -> Result { let mut rpc = build_rpc_client(rpc_url, timeout, None) .await .context("Failed to create RPC client for genesis discovery")?; @@ -161,7 +162,7 @@ async fn discover_genesis(rpc_url: &Url, timeout: Duration) -> Result { let genesis_header: BlockHeader = genesis_block_header.try_into().context("Failed to convert block header")?; - Ok(genesis_header.commitment().to_hex()) + Ok(genesis_header.commitment()) } /// Create an RPC client configured with the correct genesis metadata in the `Accept` header so that @@ -171,7 +172,7 @@ pub(crate) async fn create_genesis_aware_rpc_client( timeout: Duration, ) -> Result { let genesis = discover_genesis(rpc_url, timeout).await?; - build_rpc_client(rpc_url, timeout, Some(genesis)).await + build_rpc_client(rpc_url, timeout, Some(genesis.to_hex())).await } /// Create a pool of `size` genesis-aware RPC clients, each on its own gRPC connection. @@ -179,18 +180,22 @@ pub(crate) async fn create_genesis_aware_rpc_client( /// Genesis is discovered once and reused. Because every client owns a distinct channel, concurrent /// submissions spread across this pool ride separate HTTP/2 sockets (and, behind a load balancer, /// separate backend replicas) instead of multiplexing over a single connection. +/// +/// The discovered genesis commitment is returned alongside the pool because submissions need it to +/// seal their transaction inputs. pub(crate) async fn create_genesis_aware_rpc_client_pool( rpc_url: &Url, timeout: Duration, size: usize, -) -> Result> { +) -> Result<(Vec, Word)> { let size = size.max(1); let genesis = discover_genesis(rpc_url, timeout).await?; + let genesis_hex = genesis.to_hex(); let mut pool = Vec::with_capacity(size); for _ in 0..size { - pool.push(build_rpc_client(rpc_url, timeout, Some(genesis.clone())).await?); + pool.push(build_rpc_client(rpc_url, timeout, Some(genesis_hex.clone())).await?); } - Ok(pool) + Ok((pool, genesis)) } pub(crate) fn get_genesis_header_request() -> BlockHeaderByNumberRequest { diff --git a/bin/benchmark/src/submit.rs b/bin/benchmark/src/submit.rs index ab915d4f97..8b5a8e6f89 100644 --- a/bin/benchmark/src/submit.rs +++ b/bin/benchmark/src/submit.rs @@ -18,6 +18,7 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{Duration, Instant, SystemTime}; use miden_node_proto::clients::RpcClient; +use miden_node_proto::domain::encryption::TransactionInputSealer; use miden_node_proto::generated as proto; use miden_protocol::transaction::{ProvenTransaction, TransactionId}; use miden_protocol::utils::serde::Serializable; @@ -49,11 +50,23 @@ pub(crate) async fn run(rpc_url: Url, concurrency: usize, connections: usize, wa let consume_ids: Vec = consume_txs.iter().map(ProvenTransaction::id).collect(); println!("Connecting to {rpc_url} ({connections} connection(s))..."); - let pool = create_genesis_aware_rpc_client_pool(&rpc_url, Duration::from_secs(30), connections) - .await - .expect("failed to create RPC client pool"); + let (pool, genesis_commitment) = + create_genesis_aware_rpc_client_pool(&rpc_url, Duration::from_secs(30), connections) + .await + .expect("failed to create RPC client pool"); let pool = Arc::new(pool); + let key = pool[0] + .clone() + .get_transaction_encryption_key(()) + .await + .expect("failed to fetch the transaction encryption key") + .into_inner(); + let sealer = Arc::new( + TransactionInputSealer::new(key, genesis_commitment) + .expect("unusable transaction encryption key"), + ); + let h_start = current_block_height(pool[0].clone()).await; println!("Chain height at start: {h_start}"); @@ -62,7 +75,7 @@ pub(crate) async fn run(rpc_url: Url, concurrency: usize, connections: usize, wa submits must be serialized for the mempool to chain them)...", mint_txs.len() ); - let mint_stats = submit_sequential(pool[0].clone(), mint_txs, mint_tx_inputs).await; + let mint_stats = submit_sequential(pool[0].clone(), mint_txs, mint_tx_inputs, &sealer).await; print_phase_progress("mint", &mint_stats); println!( @@ -70,7 +83,8 @@ pub(crate) async fn run(rpc_url: Url, concurrency: usize, connections: usize, wa consume_txs.len(), pool.len(), ); - let consume_stats = submit_all(pool.clone(), consume_txs, consume_tx_inputs, concurrency).await; + let consume_stats = + submit_all(pool.clone(), consume_txs, consume_tx_inputs, concurrency, &sealer).await; print_phase_progress("consume", &consume_stats); let ack_by_id = build_ack_map(&consume_ids, &consume_stats); @@ -142,6 +156,7 @@ async fn submit_all( txs: Vec, tx_inputs: Vec>, concurrency: usize, + sealer: &Arc, ) -> PhaseStats { /// How many distinct error messages to surface to the console as they happen. The full failure /// breakdown still appears in the summary. @@ -162,10 +177,13 @@ async fn submit_all( // sockets instead of multiplexing over one channel. let mut client = pool[i % pool.len()].clone(); let printed = printed.clone(); + let sealer = sealer.clone(); set.spawn(async move { + let sealed_inputs = + sealer.seal(tx.id(), &inputs).expect("failed to seal transaction inputs"); let request = proto::transaction::ProvenTransaction { transaction: tx.to_bytes(), - transaction_inputs: Some(inputs), + sealed_transaction_inputs: Some(sealed_inputs), }; let t0 = Instant::now(); let outcome = match client.submit_proven_tx(request).await { @@ -213,15 +231,18 @@ async fn submit_sequential( mut client: RpcClient, txs: Vec, tx_inputs: Vec>, + sealer: &Arc, ) -> PhaseStats { let start = Instant::now(); let total = txs.len(); let mut outcomes = Vec::with_capacity(total); for (i, (tx, inputs)) in txs.into_iter().zip(tx_inputs).enumerate() { + let sealed_inputs = + sealer.seal(tx.id(), &inputs).expect("failed to seal transaction inputs"); let request = proto::transaction::ProvenTransaction { transaction: tx.to_bytes(), - transaction_inputs: Some(inputs), + sealed_transaction_inputs: Some(sealed_inputs), }; let t0 = Instant::now(); diff --git a/bin/network-monitor/src/counter.rs b/bin/network-monitor/src/counter.rs index a4443dd9a7..ed6aa12aa3 100644 --- a/bin/network-monitor/src/counter.rs +++ b/bin/network-monitor/src/counter.rs @@ -9,6 +9,7 @@ use std::time::{Duration, Instant}; use anyhow::{Context, Result}; use miden_node_proto::clients::RpcClient; +use miden_node_proto::domain::encryption::TransactionInputSealer; use miden_node_proto::generated::rpc::BlockHeaderByNumberRequest; use miden_node_proto::generated::transaction::ProvenTransaction; use miden_node_utils::spawn::spawn_blocking_in_current_span; @@ -54,6 +55,7 @@ use crate::deploy::{ MonitorDataStore, create_and_deploy_accounts, create_genesis_aware_rpc_client, + create_genesis_aware_rpc_client_with_commitment, }; use crate::service::Service; use crate::status::{ @@ -164,6 +166,12 @@ pub struct IncrementService { /// whenever the increment task regenerates accounts after persistent failures, so the tracker /// can switch to the new account IDs without polling disk. accounts_sender: watch::Sender, + /// Genesis commitment of the monitored network, bound into the associated data of sealed + /// transaction inputs. + genesis_commitment: Word, + /// Cached sealer for transaction inputs, populated on first submission. A plain `Option` + /// suffices because submissions run through `&mut self`. + sealer: Option, } impl IncrementService { @@ -180,8 +188,11 @@ impl IncrementService { accounts_sender: watch::Sender, latency_state: Arc>, ) -> Result { - let mut rpc_client = - create_genesis_aware_rpc_client(&config.rpc_url, config.request_timeout).await?; + let (mut rpc_client, genesis_commitment) = create_genesis_aware_rpc_client_with_commitment( + &config.rpc_url, + config.request_timeout, + ) + .await?; let (tx, details) = setup_increment_task(wallet_account, secret_key, counter_account, &mut rpc_client) .await?; @@ -194,9 +205,30 @@ impl IncrementService { details, latency_state, accounts_sender, + genesis_commitment, + sealer: None, }) } + /// Returns the cached sealer, fetching the encryption key on first use. + async fn sealer(&mut self) -> Result { + if let Some(sealer) = &self.sealer { + return Ok(sealer.clone()); + } + + let key = self + .rpc_client + .get_transaction_encryption_key(()) + .await + .context("Failed to fetch the transaction encryption key")? + .into_inner(); + let sealer = TransactionInputSealer::new(key, self.genesis_commitment) + .context("Unusable transaction encryption key")?; + + self.sealer = Some(sealer.clone()); + Ok(sealer) + } + /// Applies a successful increment: advances the local wallet by the transaction's account /// delta, bumps the success count, and returns the value used as the latency-measurement /// target. @@ -368,15 +400,28 @@ impl IncrementService { .await .context("counter increment task failed")??; + let sealed = self + .sealer() + .await? + .seal(proven_tx.id(), &tx_inputs) + .context("Failed to seal the transaction inputs")?; + let request = ProvenTransaction { transaction: proven_tx.to_bytes(), - transaction_inputs: Some(tx_inputs), + sealed_transaction_inputs: Some(sealed), }; let block_height: BlockNumber = self .rpc_client .submit_proven_tx(request) .await + .inspect_err(|status| { + // A validator restarted with a different encryption key rejects with + // `failed_precondition`. Drop the cached key so the next tick re-fetches it. + if status.code() == tonic::Code::FailedPrecondition { + self.sealer = None; + } + }) .context("Failed to submit proven transaction to RPC")? .into_inner() .block_num diff --git a/bin/network-monitor/src/deploy/mod.rs b/bin/network-monitor/src/deploy/mod.rs index c9d9720404..4a71c7604d 100644 --- a/bin/network-monitor/src/deploy/mod.rs +++ b/bin/network-monitor/src/deploy/mod.rs @@ -8,6 +8,7 @@ use std::time::Duration; use anyhow::{Context, Result}; use backon::{ExponentialBuilder, Retryable}; use miden_node_proto::clients::{Builder, RpcClient}; +use miden_node_proto::domain::encryption::TransactionInputSealer; use miden_node_proto::generated::rpc::BlockHeaderByNumberRequest; use miden_node_proto::generated::transaction::ProvenTransaction; use miden_node_utils::spawn::spawn_blocking_in_current_span; @@ -79,6 +80,19 @@ pub async fn create_genesis_aware_rpc_client( rpc_url: &Url, timeout: Duration, ) -> Result { + create_genesis_aware_rpc_client_with_commitment(rpc_url, timeout) + .await + .map(|(client, _)| client) +} + +/// As [`create_genesis_aware_rpc_client`], but also returns the discovered genesis commitment. +/// +/// Submitting call sites need the commitment to seal transaction inputs, and it is already computed +/// during the handshake. +pub async fn create_genesis_aware_rpc_client_with_commitment( + rpc_url: &Url, + timeout: Duration, +) -> Result<(RpcClient, Word)> { (|| async { // First, create a temporary client without genesis metadata to discover the genesis block // header and its commitment. @@ -126,7 +140,7 @@ pub async fn create_genesis_aware_rpc_client( .await .context("Failed to connect to RPC server with genesis metadata")?; - Ok(rpc_client) + Ok((rpc_client, genesis_commitment)) }) .retry(genesis_discovery_backoff()) .notify(|err: &anyhow::Error, sleep: Duration| { @@ -240,7 +254,8 @@ pub async fn deploy_counter_account( prover: &LocalTransactionProver, ) -> Result<()> { // Deploy counter account to the network using a genesis-aware RPC client. - let mut rpc_client = create_genesis_aware_rpc_client(rpc_url, Duration::from_secs(10)).await?; + let (mut rpc_client, genesis_commitment) = + create_genesis_aware_rpc_client_with_commitment(rpc_url, Duration::from_secs(10)).await?; let executed_tx = execute_counter_genesis_tx(counter_account, &mut rpc_client).await?; @@ -252,9 +267,19 @@ pub async fn deploy_counter_account( .context("prover task panicked")? .context("Failed to prove transaction")?; + let key = rpc_client + .get_transaction_encryption_key(()) + .await + .context("Failed to fetch the transaction encryption key")? + .into_inner(); + let sealed = TransactionInputSealer::new(key, genesis_commitment) + .context("Unusable transaction encryption key")? + .seal(proven_tx.id(), &transaction_inputs) + .context("Failed to seal the transaction inputs")?; + let request = ProvenTransaction { transaction: proven_tx.to_bytes(), - transaction_inputs: Some(transaction_inputs), + sealed_transaction_inputs: Some(sealed), }; rpc_client diff --git a/bin/ntx-builder/src/clients/rpc.rs b/bin/ntx-builder/src/clients/rpc.rs index f6562c8f3e..82310eadba 100644 --- a/bin/ntx-builder/src/clients/rpc.rs +++ b/bin/ntx-builder/src/clients/rpc.rs @@ -1,8 +1,11 @@ use std::collections::BTreeSet; use miden_node_utils::tracing::miden_instrument; +use std::sync::Arc; use std::time::{Duration, Instant}; +use tokio::sync::RwLock; + use backon::ExponentialBuilder; use futures::stream::{BoxStream, TryStreamExt}; use futures::{Stream, StreamExt}; @@ -10,6 +13,7 @@ use miden_node_proto::clients::{Builder, RpcClient as InnerRpcClient}; use miden_node_proto::domain::account::{ AccountDetails, AccountResponse, AccountVaultDetails, StorageMapEntries }; +use miden_node_proto::domain::encryption::TransactionInputSealer; use miden_node_proto::errors::ConversionError; use miden_node_proto::generated::rpc::account_request::account_detail_request::{StorageMapDetailRequest, StorageMapDetailRequests, StorageRequest, storage_map_detail_request}; use miden_node_proto::generated::rpc::account_request::account_detail_request::storage_map_detail_request::MapKeys; @@ -70,6 +74,11 @@ pub struct RpcClient { /// Backoff schedule applied to repeated `block_subscription` connection attempts. Built once at /// construction time and cloned cheaply on each retry loop. backoff: ExponentialBuilder, + /// Genesis commitment of the network being submitted to, bound into the associated data of + /// sealed transaction inputs. + genesis_commitment: Word, + /// Cached sealer for transaction inputs, fetched on first submission. + sealer: Arc>>, } impl RpcClient { @@ -112,7 +121,40 @@ impl RpcClient { let backoff = retry::exponential(backoff_initial, backoff_max); - Ok(Self { inner: rpc, backoff }) + Ok(Self { + inner: rpc, + backoff, + genesis_commitment, + sealer: Arc::new(RwLock::new(None)), + }) + } + + /// Returns a sealer for transaction inputs, fetching the encryption key on first use. + async fn sealer(&self) -> Result { + if let Some(sealer) = self.sealer.read().await.clone() { + return Ok(sealer); + } + + let mut cached = self.sealer.write().await; + // Another task may have populated the cache while this one waited for the write lock. + if let Some(sealer) = cached.clone() { + return Ok(sealer); + } + + let key = self.inner.clone().get_transaction_encryption_key(()).await?.into_inner(); + let sealer = TransactionInputSealer::new(key, self.genesis_commitment).map_err(|err| { + Status::failed_precondition( + err.as_report_context("Unusable transaction encryption key"), + ) + })?; + + *cached = Some(sealer.clone()); + Ok(sealer) + } + + /// Discards the cached sealer so the next submission re-fetches the encryption key. + async fn invalidate_sealer(&self) { + *self.sealer.write().await = None; } /// Opens a committed-block subscription starting at `block_from`, retrying indefinitely with @@ -270,12 +312,31 @@ impl RpcClient { proven_tx: &ProvenTransaction, tx_inputs: &TransactionInputs, ) -> Result<(), Status> { + let sealed = + self.sealer() + .await? + .seal(proven_tx.id(), &tx_inputs.to_bytes()) + .map_err(|err| { + Status::failed_precondition( + err.as_report_context("Failed to seal the transaction inputs"), + ) + })?; + let request = proto::transaction::ProvenTransaction { transaction: proven_tx.to_bytes(), - transaction_inputs: Some(tx_inputs.to_bytes()), + sealed_transaction_inputs: Some(sealed), }; - self.inner.clone().submit_proven_tx(request).await?; + let result = self.inner.clone().submit_proven_tx(request).await; + + // A validator restarted with a different encryption key rejects with `failed_precondition`. + // Drop the cached key so the next attempt seals against the current one. + if let Err(status) = &result + && status.code() == tonic::Code::FailedPrecondition + { + self.invalidate_sealer().await; + } + result?; Ok(()) } diff --git a/bin/validator/src/commands/mod.rs b/bin/validator/src/commands/mod.rs index 15c065e167..f6a97129f6 100644 --- a/bin/validator/src/commands/mod.rs +++ b/bin/validator/src/commands/mod.rs @@ -234,8 +234,8 @@ impl ValidatorCommand { }; let encryption_key = KeyExchangeKey::read_from_bytes(&encryption_key_bytes) .context("failed to construct the encryption key")?; - let decrypter: Arc = - Arc::new(LocalX25519TransactionInputDecrypter::new(encryption_key)); + let decrypter = LocalX25519TransactionInputDecrypter::new(encryption_key); + let decrypter: Arc = Arc::new(decrypter); let signer = if let Some(kms_key_id) = signing_key_kms_id { ValidatorSigner::new_kms(kms_key_id).await? diff --git a/bin/validator/src/server/validator_service/mod.rs b/bin/validator/src/server/validator_service/mod.rs index d27c12692d..cad68f31cf 100644 --- a/bin/validator/src/server/validator_service/mod.rs +++ b/bin/validator/src/server/validator_service/mod.rs @@ -5,6 +5,7 @@ use miden_node_db::DatabaseError; use miden_node_db::sqlite::Database; use miden_node_store::BlockStore; use miden_node_utils::tracing::{miden_instrument, miden_span_record}; +use miden_protocol::Word; use miden_protocol::block::{ BlockHeader, BlockNumber, @@ -78,8 +79,9 @@ pub enum ValidatorError { pub(crate) struct ValidatorService { signer: ValidatorSigner, /// Decrypter for transaction inputs sealed against the shared encryption key. - #[expect(dead_code, reason = "used by the submit path in a follow-up PR")] decrypter: Arc, + /// Commitment of the genesis block, loaded once at construction. + genesis_commitment: Word, /// Public metadata of the shared encryption key, fetched once at construction. encryption_key_info: TransactionEncryptionKeyInfo, /// Signature by this validator's own signing key over the encryption key attestation @@ -156,6 +158,7 @@ impl ValidatorService { Ok(Self { signer, decrypter, + genesis_commitment, encryption_key_info, encryption_key_attestation, serve_lock: Arc::new(tokio::sync::RwLock::new(())), diff --git a/bin/validator/src/server/validator_service/submit_proven_transaction.rs b/bin/validator/src/server/validator_service/submit_proven_transaction.rs index a6c76ad797..bcadbd8697 100644 --- a/bin/validator/src/server/validator_service/submit_proven_transaction.rs +++ b/bin/validator/src/server/validator_service/submit_proven_transaction.rs @@ -1,9 +1,10 @@ use std::sync::atomic::Ordering; +use miden_node_proto::domain::encryption::transaction_inputs_associated_data; use miden_node_proto::generated as grpc; use miden_node_utils::ErrorReport; use miden_node_utils::tracing::{miden_instrument, miden_span_record}; -use miden_protocol::transaction::{ProvenTransaction, TransactionInputs}; +use miden_protocol::transaction::{ProvenTransaction, TransactionId, TransactionInputs}; use miden_tx::utils::serde::Deserializable; use tonic::Status; @@ -29,17 +30,19 @@ impl grpc::server::validator_api::SubmitProvenTransaction for ValidatorService { _metadata: &tonic::metadata::MetadataMap, _extensions: &tonic::codegen::http::Extensions, ) -> tonic::Result { + let tx_id = input.tx.id(); + miden_span_record!( + transaction.id = %tx_id, + ); + + let inputs = self.unseal_transaction_inputs(&input.sealed, tx_id).await?; + // Reject requests while a backup subscription is streaming. let _guard = self .serve_lock .try_read() .map_err(|_| Status::resource_exhausted("validator is busy streaming a backup"))?; - let tx_id = input.tx.id(); - miden_span_record!( - transaction.id = %tx_id, - ); - // Short-circuit transactions that have already been validated. let already_validated = self .db @@ -53,7 +56,7 @@ impl grpc::server::validator_api::SubmitProvenTransaction for ValidatorService { } // Validate the transaction. - let tx_info = validate_transaction(input.tx, input.inputs).await.map_err(|err| { + let tx_info = validate_transaction(input.tx, inputs).await.map_err(|err| { Status::invalid_argument(err.as_report_context("Invalid transaction")) })?; @@ -74,14 +77,17 @@ impl grpc::server::validator_api::SubmitProvenTransaction for ValidatorService { let tx = ProvenTransaction::read_from_bytes(&request.transaction).map_err(|err| { Status::invalid_argument(err.as_report_context("Invalid proven transaction")) })?; - let inputs = request - .transaction_inputs - .ok_or(Status::invalid_argument("Missing transaction inputs"))?; - let inputs = TransactionInputs::read_from_bytes(&inputs).map_err(|err| { - Status::invalid_argument(err.as_report_context("Invalid transaction inputs")) + let sealed = request.sealed_transaction_inputs.ok_or_else(|| { + Status::invalid_argument( + "Missing sealed transaction inputs: fetch the encryption key with \ + GetTransactionEncryptionKey and seal the transaction inputs against it", + ) })?; + if sealed.ciphertext.is_empty() { + return Err(Status::invalid_argument("Empty sealed transaction inputs ciphertext")); + } - Ok(Self::Input { tx, inputs }) + Ok(Self::Input { tx, sealed }) } fn encode(output: Self::Output) -> tonic::Result<()> { @@ -91,5 +97,49 @@ impl grpc::server::validator_api::SubmitProvenTransaction for ValidatorService { pub struct Input { tx: ProvenTransaction, - inputs: TransactionInputs, + sealed: grpc::transaction::SealedTransactionInputs, +} + +impl ValidatorService { + /// Unseals transaction inputs submitted for `tx_id`. + async fn unseal_transaction_inputs( + &self, + sealed: &grpc::transaction::SealedTransactionInputs, + tx_id: TransactionId, + ) -> tonic::Result { + // Checked ahead of the unseal purely to turn what would otherwise be an indistinguishable + // authentication failure into an actionable one. The key identifier is public metadata, so + // there is nothing to leak by comparing it. Deliberately does not echo this validator's own + // key id: the RPC relays this status verbatim to the submitting client. + if sealed.key_id != self.encryption_key_info.key_id { + return Err(Status::failed_precondition( + "Transaction inputs were sealed against an unknown encryption key: re-fetch the \ + key with GetTransactionEncryptionKey and seal the inputs again", + )); + } + + let associated_data = transaction_inputs_associated_data( + self.encryption_key_info.scheme, + &self.encryption_key_info.key_id, + self.genesis_commitment, + tx_id, + ); + let plaintext = self + .decrypter + .decrypt_transaction_inputs(&sealed.ciphertext, &associated_data) + .await + .map_err(|err| { + // The underlying scheme collapses a wrong key, tampered ciphertext, mismatched + // associated data and corrupt framing into one error, so this cannot be any more + // specific than "it did not authenticate". `{:#}` renders the anyhow context chain, + // which `ErrorReport` cannot because it is not a `std::error::Error`. + Status::invalid_argument(format!( + "Failed to unseal the transaction inputs: {err:#}" + )) + })?; + + TransactionInputs::read_from_bytes(&plaintext).map_err(|err| { + Status::invalid_argument(err.as_report_context("Invalid transaction inputs")) + }) + } } diff --git a/bin/validator/src/server/validator_service/tests.rs b/bin/validator/src/server/validator_service/tests.rs index 15e0f57e84..b8da531d3c 100644 --- a/bin/validator/src/server/validator_service/tests.rs +++ b/bin/validator/src/server/validator_service/tests.rs @@ -1,15 +1,25 @@ use std::collections::BTreeMap; +use miden_node_proto::domain::encryption::transaction_inputs_associated_data; use miden_node_proto::generated::{self as proto}; use miden_node_proto::server::validator_api; use miden_node_store::{BlockStore, GenesisState}; use miden_node_utils::fee::test_fee_params; use miden_protocol::Word; -use miden_protocol::block::{BlockHeader, BlockInputs, ProposedBlock}; +use miden_protocol::account::AccountUpdateDetails; +use miden_protocol::block::{BlockHeader, BlockInputs, BlockNumber, ProposedBlock}; use miden_protocol::crypto::dsa::ecdsa_k256_keccak::{Signature, SigningKey}; use miden_protocol::crypto::dsa::eddsa_25519_sha512::KeyExchangeKey; use miden_protocol::testing::random_secret_key::random_secret_key; -use miden_protocol::transaction::PartialBlockchain; +use miden_protocol::transaction::{ + InputNoteCommitment, + OutputNote, + PartialBlockchain, + ProvenTransaction, + TransactionId, + TxAccountUpdate, +}; +use miden_protocol::vm::ExecutionProof; use miden_tx::utils::serde::{Deserializable, Serializable}; use super::{ValidatorError, ValidatorService}; @@ -73,6 +83,44 @@ impl TestValidator { empty_block(&self.chain_tip, &self.chain) } + /// Calls `submit_proven_transaction` on the validator server. + async fn call_submit_proven_transaction( + &self, + tx: &ProvenTransaction, + sealed: Option, + ) -> Result<(), tonic::Status> { + let request = tonic::Request::new(proto::transaction::ProvenTransaction { + transaction: tx.to_bytes(), + sealed_transaction_inputs: sealed, + }); + validator_api::SubmitProvenTransaction::full(&self.server, request).await + } + + /// Seals `plaintext` exactly as a well-behaved client would: against the key this validator + /// serves, bound to `tx_id` and this network's genesis commitment. + fn seal( + &self, + tx_id: TransactionId, + plaintext: &[u8], + ) -> proto::transaction::SealedTransactionInputs { + let key = &self.server.encryption_key_info; + let associated_data = transaction_inputs_associated_data( + key.scheme, + &key.key_id, + self.server.genesis_commitment, + tx_id, + ); + let sealed = test_decrypter() + .sealing_key() + .seal_bytes_with_associated_data(&mut rand::rng(), plaintext, &associated_data) + .expect("sealing should succeed"); + + proto::transaction::SealedTransactionInputs { + key_id: key.key_id.clone(), + ciphertext: sealed.to_bytes(), + } + } + /// Calls `sign_block` on the validator server. async fn call_sign_block( &self, @@ -195,6 +243,33 @@ fn empty_block(parent_header: &BlockHeader, chain: &PartialBlockchain) -> Propos ProposedBlock::new(block_inputs, vec![]).unwrap() } +/// Builds a syntactically valid [`ProvenTransaction`] with a dummy proof. +fn dummy_proven_tx(seed: u8) -> ProvenTransaction { + let account_update = TxAccountUpdate::new( + miden_protocol::testing::account_id::ACCOUNT_ID_PRIVATE_SENDER + .try_into() + .unwrap(), + Word::empty(), + Word::from([u32::from(seed), 0, 0, 0]), + Word::empty(), + AccountUpdateDetails::Private, + ) + .unwrap(); + + // The account state changes, which is what keeps this from being rejected as an empty + // transaction; no input or output notes are needed. + ProvenTransaction::new( + account_update, + Vec::::new(), + Vec::::new(), + BlockNumber::GENESIS, + Word::empty(), + BlockNumber::from(u32::from(seed) + 1), + ExecutionProof::new_dummy(), + ) + .unwrap() +} + // TESTS // ================================================================================================ @@ -901,3 +976,89 @@ async fn encryption_key_available_during_backup() { drop(stream); } + +// SUBMIT PATH: TRANSACTION INPUT SEALING +// ================================================================================================ + +/// Plaintext transaction inputs must be impossible to submit. This is the central guarantee of the +/// whole change. +#[tokio::test] +async fn submit_rejects_plaintext_inputs() { + let tv = TestValidator::new().await; + let tx = dummy_proven_tx(3); + let sealed = proto::transaction::SealedTransactionInputs { + key_id: tv.server.encryption_key_info.key_id.clone(), + ciphertext: b"not a sealed message, just bytes".to_vec(), + }; + + let status = tv.call_submit_proven_transaction(&tx, Some(sealed)).await.unwrap_err(); + + assert_eq!(status.code(), tonic::Code::InvalidArgument); + assert!(status.message().contains("unseal"), "got: {}", status.message()); +} + +/// A key id that does not match the validator's earns a distinct, actionable status so a client +/// knows to re-fetch rather than retry the same blob, without disclosing the validator's own key id. +#[tokio::test] +async fn submit_rejects_unknown_key_id() { + let tv = TestValidator::new().await; + let tx = dummy_proven_tx(4); + let mut sealed = tv.seal(tx.id(), b"transaction inputs"); + sealed.key_id = vec![0xAA, 0xBB, 0xCC, 0xDD]; + + let status = tv.call_submit_proven_transaction(&tx, Some(sealed)).await.unwrap_err(); + + assert_eq!(status.code(), tonic::Code::FailedPrecondition); + assert!( + status.message().contains("GetTransactionEncryptionKey"), + "the rejection must tell the client to re-fetch the key, got: {}", + status.message(), + ); + // This status reaches the client verbatim through the RPC. + let own_key_id = hex::encode(&tv.server.encryption_key_info.key_id); + assert!( + !status.message().contains(&own_key_id), + "the rejection must not echo the validator's key id", + ); +} + +/// The validator enforces the associated data, so a ciphertext captured from one transaction cannot +/// be replayed onto another. Which fields the transcript covers is pinned separately by the golden +/// vector in `miden_node_proto::domain::encryption`. +#[tokio::test] +async fn submit_rejects_inputs_sealed_for_a_different_transaction() { + let tv = TestValidator::new().await; + let tx_a = dummy_proven_tx(6); + let tx_b = dummy_proven_tx(7); + assert_ne!(tx_a.id(), tx_b.id()); + + let sealed_for_a = tv.seal(tx_a.id(), b"transaction inputs"); + + let status = tv.call_submit_proven_transaction(&tx_b, Some(sealed_for_a)).await.unwrap_err(); + + assert_eq!(status.code(), tonic::Code::InvalidArgument); + assert!(status.message().contains("unseal"), "got: {}", status.message()); +} + +/// Correctly sealed inputs get past the unseal and fail later, at deserialization. Without this the +/// tests above would all still pass if the unseal simply always failed. +#[tokio::test] +async fn correctly_sealed_inputs_reach_the_deserialization_stage() { + let tv = TestValidator::new().await; + let tx = dummy_proven_tx(10); + let sealed = tv.seal(tx.id(), b"not really transaction inputs"); + + let status = tv.call_submit_proven_transaction(&tx, Some(sealed)).await.unwrap_err(); + + assert_eq!(status.code(), tonic::Code::InvalidArgument); + assert!( + status.message().contains("Invalid transaction inputs"), + "the unseal should have succeeded and failed at deserialization instead, got: {}", + status.message(), + ); + assert!( + !status.message().contains("unseal"), + "the unseal must have succeeded, got: {}", + status.message(), + ); +} diff --git a/bin/validator/src/signers/mod.rs b/bin/validator/src/signers/mod.rs index 6cb3a476e7..074bec00d7 100644 --- a/bin/validator/src/signers/mod.rs +++ b/bin/validator/src/signers/mod.rs @@ -257,9 +257,16 @@ impl TransactionInputDecrypter for LocalX25519TransactionInputDecrypter { let message = SealedMessage::read_from_bytes(ciphertext) .context("failed to deserialize the sealed message")?; - UnsealingKey::X25519XChaCha20Poly1305(self.secret_key.clone()) - .unseal_bytes_with_associated_data(message, associated_data) - .context("failed to unseal the transaction inputs") + + let secret_key = self.secret_key.clone(); + let associated_data = associated_data.to_vec(); + spawn_blocking_in_current_span(move || { + UnsealingKey::X25519XChaCha20Poly1305(secret_key) + .unseal_bytes_with_associated_data(message, &associated_data) + .context("AEAD authentication failed") + }) + .await + .unwrap_or_else(|e| std::panic::resume_unwind(e.into_panic())) } } diff --git a/crates/proto/Cargo.toml b/crates/proto/Cargo.toml index 9d8ce09b4c..5cde8ca82f 100644 --- a/crates/proto/Cargo.toml +++ b/crates/proto/Cargo.toml @@ -23,6 +23,7 @@ miden-node-utils = { workspace = true } miden-protocol = { workspace = true } miden-standards = { workspace = true } prost = { workspace = true } +rand = { workspace = true } thiserror = { workspace = true } tonic = { default-features = true, workspace = true } tonic-prost = { workspace = true } @@ -30,6 +31,7 @@ tower = { workspace = true } url = { workspace = true } [dev-dependencies] +assert_matches = { workspace = true } miden-protocol = { features = ["testing"], workspace = true } proptest = { version = "1.7" } diff --git a/crates/proto/src/clients/mod.rs b/crates/proto/src/clients/mod.rs index cbca641d3e..4f41dbfcf3 100644 --- a/crates/proto/src/clients/mod.rs +++ b/crates/proto/src/clients/mod.rs @@ -530,21 +530,21 @@ impl ValidatorClient { /// /// # Errors /// - /// - If `transaction_inputs` does not match the batch's transactions in length + /// - If `sealed_transaction_inputs` does not match the batch's transactions in length pub async fn submit_batch( &mut self, proposed_batch: &ProposedBatch, - transaction_inputs: &[Vec], + sealed_transaction_inputs: &[generated::transaction::SealedTransactionInputs], ) -> Result<(), Status> { - if proposed_batch.transactions().len() != transaction_inputs.len() { + if proposed_batch.transactions().len() != sealed_transaction_inputs.len() { return Err(Status::invalid_argument( "transaction inputs do not match the batch's transactions", )); } - for (tx, inputs) in proposed_batch.transactions().iter().zip(transaction_inputs) { + for (tx, inputs) in proposed_batch.transactions().iter().zip(sealed_transaction_inputs) { let proven_tx = generated::transaction::ProvenTransaction { transaction: tx.to_bytes(), - transaction_inputs: Some(inputs.clone()), + sealed_transaction_inputs: Some(inputs.clone()), }; self.submit_proven_transaction(proven_tx).await?; } diff --git a/crates/proto/src/domain/encryption.rs b/crates/proto/src/domain/encryption.rs new file mode 100644 index 0000000000..1b79e73c9e --- /dev/null +++ b/crates/proto/src/domain/encryption.rs @@ -0,0 +1,258 @@ +//! Sealing of transaction inputs against the validator set's shared encryption key. +//! +//! This module is the single definition of the associated-data transcript, so the sealing side +//! (clients and the node's own submitters) and the unsealing side (the validator) cannot drift. +//! A drift would not fail to compile: it would reject every submission at runtime with an opaque +//! AEAD error, so the transcript is pinned by a golden vector in the tests below. + +use miden_protocol::Word; +use miden_protocol::crypto::dsa::eddsa_25519_sha512::PublicKey as EncryptionPublicKey; +use miden_protocol::crypto::ies::{IesScheme, SealingKey}; +use miden_protocol::transaction::TransactionId; +use miden_protocol::utils::serde::{Deserializable, Serializable}; + +use crate::generated as proto; + +/// Domain tag prefixed to the associated data of sealed transaction inputs. +/// +/// Separates this transcript from every other use of the same key material, in particular from the +/// key attestation signed with the validator's signing key. +pub const TX_INPUT_SEAL_DOMAIN: &[u8] = b"MIDEN_TX_INPUT_SEAL_V1"; + +/// Upper bound on the length of an encryption key identifier. +/// +/// Key identifiers are 4 bytes today (the leading bytes of the public key commitment). The bound +/// exists so that a hostile or misconfigured key endpoint cannot drive an unbounded allocation, and +/// so that the length cast in the transcript cannot overflow. +pub const MAX_KEY_ID_LEN: usize = 64; + +/// Wire identifier of the only IES scheme the node currently supports. +const SCHEME_X25519_XCHACHA20_POLY1305: u32 = 1; + +// ASSOCIATED DATA +// ================================================================================================ + +/// Builds the associated data authenticating a sealed set of transaction inputs. +/// +/// This is the single definition of the transcript. Both sides derive it independently and it is +/// never transmitted, so a mismatch surfaces as an authentication failure rather than as accepted +/// but unauthenticated data. +/// +/// The layout is `TX_INPUT_SEAL_DOMAIN || scheme || len(key_id) || key_id || genesis_commitment || +/// transaction_id`, where the scheme and the length prefix are 4 bytes little-endian. The domain tag +/// is a fixed-width constant, `scheme` is fixed-width, `key_id` is length-prefixed and the two +/// trailing fields are a fixed 32 bytes each, so no two distinct inputs produce the same transcript. +/// +/// Each binding serves a purpose: +/// - `scheme` and `key_id` tie the blob to one key, so inputs sealed against a retired key fail to +/// authenticate rather than silently decrypting. +/// - `genesis_commitment` ties the blob to one network. This matters in practice because every +/// development stack shares the same insecure default key, so without it a blob captured on one +/// network would replay onto another. +/// - `transaction_id` ties the blob to one transaction, so a captured blob cannot be replayed onto a +/// different transaction. +/// +/// Deliberately absent is the serialized transaction. The RPC rebuilds `ProvenTransaction` with +/// output-note decorators stripped before forwarding a submission, so binding those bytes would +/// reject every relayed transaction. The transaction id is invariant under that rebuild, which is +/// why it is bound instead. +pub fn transaction_inputs_associated_data( + scheme: u32, + key_id: &[u8], + genesis_commitment: Word, + tx_id: TransactionId, +) -> Vec { + let genesis_commitment = genesis_commitment.to_bytes(); + let tx_id = tx_id.as_word().to_bytes(); + let mut transcript = Vec::with_capacity( + TX_INPUT_SEAL_DOMAIN.len() + + 2 * size_of::() + + key_id.len() + + genesis_commitment.len() + + tx_id.len(), + ); + transcript.extend_from_slice(TX_INPUT_SEAL_DOMAIN); + transcript.extend_from_slice(&scheme.to_le_bytes()); + // Callers bound `key_id` to MAX_KEY_ID_LEN, so this cast cannot realistically fail. Saturate + // rather than panic anyway: this runs inside a request handler on the validator. + let key_id_len = u32::try_from(key_id.len()).unwrap_or(u32::MAX); + transcript.extend_from_slice(&key_id_len.to_le_bytes()); + transcript.extend_from_slice(key_id); + transcript.extend_from_slice(&genesis_commitment); + transcript.extend_from_slice(&tx_id); + transcript +} + +// ERRORS +// ================================================================================================ + +/// Failure to build a sealer from a served encryption key, or to seal with it. +#[derive(Debug, thiserror::Error)] +pub enum TransactionInputSealError { + #[error("encryption key scheme is unspecified")] + UnspecifiedScheme, + #[error("unsupported encryption key scheme {0}")] + UnsupportedScheme(i32), + #[error("encryption key id is {len} bytes, which exceeds the maximum of {MAX_KEY_ID_LEN}")] + KeyIdTooLong { len: usize }, + #[error("invalid encryption public key")] + InvalidPublicKey(#[source] miden_protocol::utils::serde::DeserializationError), + #[error("failed to seal the transaction inputs")] + Seal(#[source] miden_protocol::crypto::ies::IesError), +} + +// SEALER +// ================================================================================================ + +/// Seals transaction inputs against the validator set's shared encryption key. +/// +/// Built from a `GetTransactionEncryptionKey` response and reusable for any number of transactions. +/// Holding one avoids re-fetching the key per submission; callers should discard it when the +/// validator reports an unknown key id, which is how a key change is detected. +#[derive(Debug, Clone)] +pub struct TransactionInputSealer { + scheme: u32, + key_id: Vec, + sealing_key: SealingKey, + genesis_commitment: Word, +} + +impl TransactionInputSealer { + /// Builds a sealer from a served encryption key and the genesis commitment of the network the + /// inputs will be submitted to. + pub fn new( + key: proto::transaction::TransactionEncryptionKey, + genesis_commitment: Word, + ) -> Result { + // Match the wire value explicitly. The proto enum reserves 0 for "unspecified" while + // `IesScheme` uses 0 for K256, so converting straight from the raw value would silently + // select a scheme the node does not serve instead of reporting an error. + let scheme = match key.scheme { + 0 => return Err(TransactionInputSealError::UnspecifiedScheme), + 1 => SCHEME_X25519_XCHACHA20_POLY1305, + other => return Err(TransactionInputSealError::UnsupportedScheme(other)), + }; + debug_assert_eq!( + u32::from(u8::from(IesScheme::X25519XChaCha20Poly1305)), + scheme, + "the wire scheme identifier must match the crypto discriminant", + ); + + if key.key_id.len() > MAX_KEY_ID_LEN { + return Err(TransactionInputSealError::KeyIdTooLong { len: key.key_id.len() }); + } + + let public_key = EncryptionPublicKey::read_from_bytes(&key.public_key) + .map_err(TransactionInputSealError::InvalidPublicKey)?; + + Ok(Self { + scheme, + key_id: key.key_id, + sealing_key: SealingKey::X25519XChaCha20Poly1305(public_key), + genesis_commitment, + }) + } + + /// The identifier of the key this sealer seals against. + pub fn key_id(&self) -> &[u8] { + &self.key_id + } + + /// Seals `transaction_inputs` for the transaction identified by `tx_id`. + /// + /// `transaction_inputs` must be the encoding of + /// [`miden_protocol::transaction::TransactionInputs::to_bytes`]. + /// + /// Each call draws a fresh ephemeral key, so sealing the same inputs twice is safe and yields + /// different ciphertexts. + pub fn seal( + &self, + tx_id: TransactionId, + transaction_inputs: &[u8], + ) -> Result { + let associated_data = transaction_inputs_associated_data( + self.scheme, + &self.key_id, + self.genesis_commitment, + tx_id, + ); + let sealed = self + .sealing_key + .seal_bytes_with_associated_data(&mut rand::rng(), transaction_inputs, &associated_data) + .map_err(TransactionInputSealError::Seal)?; + + Ok(proto::transaction::SealedTransactionInputs { + key_id: self.key_id.clone(), + ciphertext: sealed.to_bytes(), + }) + } +} + +// TESTS +// ================================================================================================ + +#[cfg(test)] +mod tests { + use assert_matches::assert_matches; + use miden_protocol::crypto::dsa::eddsa_25519_sha512::KeyExchangeKey; + + use super::*; + + const TEST_KEY_ID: [u8; 4] = [0xDE, 0xAD, 0xBE, 0xEF]; + + fn genesis() -> Word { + Word::from([1u32, 2, 3, 4]) + } + + fn tx_id(seed: u32) -> TransactionId { + TransactionId::new( + Word::from([seed, 0, 0, 0]), + Word::from([0, seed, 0, 0]), + Word::from([0, 0, seed, 0]), + Word::from([0, 0, 0, seed]), + ) + } + + /// Pins the transcript byte-for-byte, which also pins *which* fields it binds. + /// + /// Both sides derive the transcript through this one function, so a change to it would pass + /// every other test in the workspace and surface only as every submission on the network failing + /// to authenticate. This vector is the only thing that catches that. + #[test] + fn associated_data_is_stable() { + let ad = transaction_inputs_associated_data(1, &TEST_KEY_ID, genesis(), tx_id(10)); + + let mut expected = Vec::new(); + expected.extend_from_slice(b"MIDEN_TX_INPUT_SEAL_V1"); + expected.extend_from_slice(&1u32.to_le_bytes()); + expected.extend_from_slice(&4u32.to_le_bytes()); + expected.extend_from_slice(&TEST_KEY_ID); + expected.extend_from_slice(&genesis().to_bytes()); + expected.extend_from_slice(&tx_id(10).as_word().to_bytes()); + + assert_eq!(ad, expected); + // 22-byte tag + 4 scheme + 4 length + 4 key id + 32 genesis + 32 transaction id. + assert_eq!(ad.len(), 98); + } + + /// Scheme 0 means "unspecified" on the wire but K256 in `IesScheme`, so converting the raw value + /// would silently select a scheme the node does not serve instead of erroring. + #[test] + fn sealer_rejects_unspecified_scheme() { + let key = proto::transaction::TransactionEncryptionKey { + scheme: 0, + key_id: TEST_KEY_ID.to_vec(), + public_key: KeyExchangeKey::read_from_bytes(&[7u8; 32]) + .unwrap() + .public_key() + .to_bytes(), + attestations: Vec::new(), + next_key: None, + }; + + assert_matches!( + TransactionInputSealer::new(key, genesis()), + Err(TransactionInputSealError::UnspecifiedScheme) + ); + } +} diff --git a/crates/proto/src/domain/mod.rs b/crates/proto/src/domain/mod.rs index e04eff7947..d19046cb28 100644 --- a/crates/proto/src/domain/mod.rs +++ b/crates/proto/src/domain/mod.rs @@ -2,6 +2,7 @@ pub mod account; pub mod batch; pub mod block; pub mod digest; +pub mod encryption; pub mod merkle; pub mod note; pub mod nullifier; diff --git a/crates/rpc/src/server/api.rs b/crates/rpc/src/server/api.rs index 8e68600bd6..c401c523f3 100644 --- a/crates/rpc/src/server/api.rs +++ b/crates/rpc/src/server/api.rs @@ -1,7 +1,7 @@ use std::num::NonZeroUsize; use std::ops::RangeInclusive; use std::sync::{Arc, LazyLock}; -use std::time::Duration; +use std::time::{Duration, Instant}; use anyhow::Context as AnyhowContext; use miden_node_block_producer::BlockProducerApi; @@ -27,7 +27,7 @@ use miden_node_utils::tracing::miden_instrument; use miden_protocol::Word; use miden_protocol::account::AccountId; use miden_protocol::block::{BlockHeader, BlockNumber}; -use tokio::sync::Semaphore; +use tokio::sync::{Mutex as TokioMutex, Semaphore}; use tonic::metadata::MetadataMap; use tonic::{IntoRequest, Request, Status}; @@ -84,8 +84,18 @@ pub struct RpcService { block_subscription_semaphore: Arc, proof_subscription_semaphore: Arc, subscription_ban: Arc, + encryption_key_cache: Arc>>, } +/// A cached `GetTransactionEncryptionKey` response and the time it was fetched. +struct CachedEncryptionKey { + key: proto::transaction::TransactionEncryptionKey, + fetched_at: Instant, +} + +/// How long a cached transaction encryption key is served before being refetched. +const ENCRYPTION_KEY_CACHE_TTL: Duration = Duration::from_secs(30); + impl RpcService { pub(crate) fn new( store: Arc, @@ -104,6 +114,7 @@ impl RpcService { block_subscription_semaphore: Arc::new(Semaphore::new(MAX_REPLICA_SUBSCRIPTIONS)), proof_subscription_semaphore: Arc::new(Semaphore::new(MAX_REPLICA_SUBSCRIPTIONS)), subscription_ban: Arc::new(IpBanList::default()), + encryption_key_cache: Arc::new(TokioMutex::new(None)), } } diff --git a/crates/rpc/src/server/api/get_transaction_encryption_key.rs b/crates/rpc/src/server/api/get_transaction_encryption_key.rs index 2c85f94af5..810e3373d5 100644 --- a/crates/rpc/src/server/api/get_transaction_encryption_key.rs +++ b/crates/rpc/src/server/api/get_transaction_encryption_key.rs @@ -1,8 +1,10 @@ +use std::time::Instant; + use miden_node_proto::generated as proto; use miden_node_utils::tracing::miden_instrument; use tracing::debug; -use super::{Request, RpcMode, RpcService}; +use super::{CachedEncryptionKey, ENCRYPTION_KEY_CACHE_TTL, Request, RpcMode, RpcService}; use crate::{COMPONENT, LOG_TARGET}; #[tonic::async_trait] @@ -34,13 +36,20 @@ impl proto::server::rpc_api::GetTransactionEncryptionKey for RpcService { debug!(target: LOG_TARGET, "Getting transaction encryption key"); + let mut cache = self.encryption_key_cache.lock().await; + if let Some(cached) = cache.as_ref() + && cached.fetched_at.elapsed() < ENCRYPTION_KEY_CACHE_TTL + { + return Ok(cached.key.clone()); + } + let mut forwarded_request = Request::new(()); if let Some(accept) = original_accept_header { forwarded_request.metadata_mut().insert(http::header::ACCEPT.as_str(), accept); } // Nodes connected to a validator ask for it directly, otherwise the request is forwarded. - match &self.mode { + let key = match &self.mode { RpcMode::Sequencer { validator, .. } | RpcMode::FullNode { validator: Some(validator), .. } => validator .as_ref() @@ -54,6 +63,13 @@ impl proto::server::rpc_api::GetTransactionEncryptionKey for RpcService { .get_transaction_encryption_key(forwarded_request) .await .map(tonic::Response::into_inner), - } + }?; + + *cache = Some(CachedEncryptionKey { + key: key.clone(), + fetched_at: Instant::now(), + }); + + Ok(key) } } diff --git a/crates/rpc/src/server/api/submit_proven_tx.rs b/crates/rpc/src/server/api/submit_proven_tx.rs index 4aebd7e401..e4a574ee87 100644 --- a/crates/rpc/src/server/api/submit_proven_tx.rs +++ b/crates/rpc/src/server/api/submit_proven_tx.rs @@ -49,7 +49,7 @@ impl proto::server::rpc_api::SubmitProvenTx for RpcService { let is_authorized_network_tx = self.is_authorized_network_tx(metadata); let original_accept_header = metadata.get(http::header::ACCEPT.as_str()).cloned(); - tracing::trace!(target: LOG_TARGET, ?request); + tracing::trace!(target: LOG_TARGET, "Received transaction submission"); let tx = ProvenTransaction::read_from_bytes(&request.transaction).map_err(|err| { Status::invalid_argument(err.as_report_context("invalid transaction")) diff --git a/crates/rpc/src/server/api/submit_proven_tx_batch.rs b/crates/rpc/src/server/api/submit_proven_tx_batch.rs index 2dedae98c4..f55807c7f2 100644 --- a/crates/rpc/src/server/api/submit_proven_tx_batch.rs +++ b/crates/rpc/src/server/api/submit_proven_tx_batch.rs @@ -42,7 +42,7 @@ impl proto::server::rpc_api::SubmitProvenTxBatch for RpcService { let is_authorized_network_tx = self.is_authorized_network_tx(metadata); let original_accept_header = metadata.get(http::header::ACCEPT.as_str()).cloned(); - tracing::trace!(target: LOG_TARGET, ?request); + tracing::trace!(target: LOG_TARGET, batch_size = request.sealed_transaction_inputs.len()); let proven_batch = ProvenBatch::read_from_bytes(&request.batch_proof).map_err(|err| { Status::invalid_argument(err.as_report_context("invalid proven_batch")) @@ -76,10 +76,10 @@ impl proto::server::rpc_api::SubmitProvenTxBatch for RpcService { // Perform this check here since its cheap. If this passes we can safely zip inputs and // transactions. - if request.transaction_inputs.len() != proposed_batch.transactions().len() { + if request.sealed_transaction_inputs.len() != proposed_batch.transactions().len() { return Err(Status::invalid_argument(format!( "Number of inputs {} does not match number of transaction {} in batch", - request.transaction_inputs.len(), + request.sealed_transaction_inputs.len(), proposed_batch.transactions().len() ))); } @@ -108,7 +108,7 @@ impl proto::server::rpc_api::SubmitProvenTxBatch for RpcService { RpcMode::Sequencer { block_producer, validator } => { validator .clone() - .submit_batch(&proposed_batch, &request.transaction_inputs) + .submit_batch(&proposed_batch, &request.sealed_transaction_inputs) .await?; block_producer .submit_proven_tx_batch(proposed_batch) @@ -125,7 +125,7 @@ impl proto::server::rpc_api::SubmitProvenTxBatch for RpcService { *validator.clone(), *sequencer.clone(), proposed_batch, - &request.transaction_inputs, + &request.sealed_transaction_inputs, ) .await }, @@ -164,9 +164,9 @@ impl RpcService { mut validator: ValidatorClient, mut sequencer: SequencerClient, proposed_batch: ProposedBatch, - transaction_inputs: &[Vec], + sealed_transaction_inputs: &[proto::transaction::SealedTransactionInputs], ) -> tonic::Result { - validator.submit_batch(&proposed_batch, transaction_inputs).await?; + validator.submit_batch(&proposed_batch, sealed_transaction_inputs).await?; let mut auth_inputs = Vec::with_capacity(proposed_batch.transactions().len()); for tx in proposed_batch.transactions() { diff --git a/crates/rpc/src/tests.rs b/crates/rpc/src/tests.rs index af93ea1b1d..913e958e8f 100644 --- a/crates/rpc/src/tests.rs +++ b/crates/rpc/src/tests.rs @@ -440,7 +440,7 @@ async fn rpc_server_rejects_proven_transactions_with_invalid_commitment() { let request = proto::transaction::ProvenTransaction { transaction: tx_bytes, - transaction_inputs: None, + sealed_transaction_inputs: None, }; let response = rpc_client.submit_proven_tx(request).await; @@ -479,7 +479,7 @@ async fn rpc_server_rejects_proven_transactions_with_invalid_reference_block() { let request = proto::transaction::ProvenTransaction { transaction: tx.to_bytes(), - transaction_inputs: None, + sealed_transaction_inputs: None, }; let response = rpc_client.submit_proven_tx(request).await; @@ -518,7 +518,7 @@ async fn rpc_rejects_post_deployment_network_account_tx() { let tx = build_test_proven_tx_with_id(network_account_id, &account, genesis); let request = proto::transaction::ProvenTransaction { transaction: tx.to_bytes(), - transaction_inputs: None, + sealed_transaction_inputs: None, }; let service = RpcService::new( @@ -1041,7 +1041,7 @@ async fn rpc_server_rejects_tx_submissions_without_genesis() { let request = proto::transaction::ProvenTransaction { transaction: tx.to_bytes(), - transaction_inputs: None, + sealed_transaction_inputs: None, }; let response = rpc_client.submit_proven_tx(request).await; diff --git a/docker-compose.yml b/docker-compose.yml index 7c27db28f4..da9d9731b5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -132,6 +132,9 @@ services: pull_policy: if_not_present volumes: - node-data:/data + environment: + - MIDEN_VALIDATOR_ENCRYPTION_KEY + - MIDEN_VALIDATOR_ENCRYPTION_KEY_KMS_CIPHERTEXT depends_on: bootstrap-validator: condition: service_completed_successfully diff --git a/docs/external/src/full-node/rpc.md b/docs/external/src/full-node/rpc.md index d2642dbd72..a3b6677c67 100644 --- a/docs/external/src/full-node/rpc.md +++ b/docs/external/src/full-node/rpc.md @@ -16,7 +16,11 @@ Read queries are served from the full node's local state. This includes account, Two endpoints are exceptions because they depend on state that is not replicated. The network-note debugging endpoint, `GetNetworkNoteStatus`, depends on NTX builder state, so full nodes forward it to the configured upstream RPC source. `GetTransactionEncryptionKey` depends on a validator: full nodes with a validator connected forward it there, and full -nodes without one forward it to the upstream RPC source. +nodes without one forward it to the upstream RPC source. Responses are cached briefly, since every submitting client +must fetch the key first and a validator's answer does not change while it is running. + +Because sealing transaction inputs is mandatory, this endpoint is on the critical path for submission: a full node that +cannot reach a validator or its upstream source can no longer serve submitting clients at all, not merely the key query. ## Transaction Submission diff --git a/docs/external/src/network-operator/bootstrap-and-genesis.md b/docs/external/src/network-operator/bootstrap-and-genesis.md index cff75a3ee3..004686a8ec 100644 --- a/docs/external/src/network-operator/bootstrap-and-genesis.md +++ b/docs/external/src/network-operator/bootstrap-and-genesis.md @@ -100,4 +100,9 @@ miden-ntx-builder bootstrap \ The validator key used during bootstrap must match the key used when starting the validator for the network. +Bootstrap takes no transaction encryption key: that key is configured separately when the validator is started, and +nothing cross-checks it against the genesis block. A validator started without one falls back to a publicly known +insecure default, which after bootstrap means every submission on the network is encrypted to a key anyone can read. See +[Validator](./validator.md) for how to provision it. + diff --git a/docs/external/src/network-operator/validator.md b/docs/external/src/network-operator/validator.md index 29032750b2..930b34489a 100644 --- a/docs/external/src/network-operator/validator.md +++ b/docs/external/src/network-operator/validator.md @@ -18,7 +18,8 @@ the sequencer or full-node replicas lose data. The validator is also a temporary training-wheels layer while the proof and VM systems mature. It receives the private inputs needed to independently check proposed blocks, which gives the network another place to detect bugs before a -block is committed. +block is committed. Those inputs arrive encrypted against the shared transaction encryption key, so the validator is the +only component that can read them, and submissions that are not encrypted are rejected. ## Key Rotation @@ -40,7 +41,8 @@ configure validator signing explicitly, either with a local key or with KMS-back In addition to its signing key, every validator holds the shared transaction encryption key, configured with `--encryption-key.hex` or `MIDEN_VALIDATOR_ENCRYPTION_KEY`. Unlike the signing key, this value must be identical across -every validator in the set. The validator logs a warning at startup if the insecure development default is in use. +every validator in the set. The validator logs a warning at startup if the insecure development default is in use, and +always logs the resolved key id so you can confirm which key is live. Production deployments should not pass the secret in plaintext. Instead, wrap it with a symmetric AWS KMS key (`aws kms encrypt`) and pass the resulting base64 ciphertext blob unchanged via `--encryption-key.kms-ciphertext` or diff --git a/docs/external/src/rpc/errors-and-limits.md b/docs/external/src/rpc/errors-and-limits.md index 5dcd6fe567..defab15cc6 100644 --- a/docs/external/src/rpc/errors-and-limits.md +++ b/docs/external/src/rpc/errors-and-limits.md @@ -53,6 +53,18 @@ conflict, and use the detail byte when a client needs stable branching between b `CapacityExceeded` means the mempool capacity has been exhausted and is under load. +### Encrypted input errors + +Rejections caused by the sealed transaction inputs happen before the mempool, so they carry no Miden detail code and +fall into the ordinary-gRPC-status bucket described above: + +- `INVALID_ARGUMENT` when the sealed inputs are absent, empty, or fail to authenticate. Failing to authenticate is + deliberately indistinguishable between a wrong key, tampered ciphertext, a blob sealed for a different transaction or + network, and corrupt framing. +- `FAILED_PRECONDITION` when the inputs were sealed against a key the validator does not hold. This is the one case a + client can act on: re-fetch `GetTransactionEncryptionKey` and seal again. Treat it as non-retryable without + re-sealing, and back off rather than retrying in a tight loop, since the key fetch is itself rate limited. + ## Request Limits Use `GetLimits` to discover method-specific request limits before sending large sync requests. Methods such as diff --git a/docs/external/src/rpc/public-api.md b/docs/external/src/rpc/public-api.md index 5e92b9b5e3..f2c73c72e4 100644 --- a/docs/external/src/rpc/public-api.md +++ b/docs/external/src/rpc/public-api.md @@ -38,14 +38,16 @@ grpcurl rpc.testnet.miden.io:443 describe rpc.Api | `SubmitProvenTx` | Submits one proven transaction and returns the node's current block height. | | `SubmitProvenTxBatch` | Submits an atomic batch of proven transactions and returns the node's current block height. | +Fetching the encryption key is a **required first step** before submitting. Both submit methods carry their private +transaction inputs sealed against that key, and a submission with missing or unsealable inputs is rejected. For a batch, +each transaction's inputs are sealed independently against that transaction's own id. + The public key returned by `GetTransactionEncryptionKey` is shared across the whole validator set, while each attestation is specific to one validator (currently the response carries a single attestation). Clients verify an attestation against a validator signing key they already trust from the chain and reconstruct the encryption key with -miden-crypto. The exact attestation payload is documented on the `TransactionEncryptionKey` proto message. Note that -this scheme does not hide transaction inputs from holders of the shared encryption secret (currently the network -operator and every validator) and provides no forward secrecy. The attestation proves which validator vouched for the -key but does not prove freshness: after a key rotation, a replayed older signed key still verifies until a chain or -epoch rule for freshness exists. +miden-crypto. The exact attestation payload, and the associated data that binds a sealed submission to one key, one +network and one transaction, are documented on the `TransactionEncryptionKey` and `SealedTransactionInputs` proto +messages. Write requests must identify the target network with the `genesis` parameter in the `Accept` header: diff --git a/docs/internal/src/validator.md b/docs/internal/src/validator.md index 5e8f5ed3e8..c4132bc199 100644 --- a/docs/internal/src/validator.md +++ b/docs/internal/src/validator.md @@ -11,9 +11,9 @@ by a separate entity. The validator's public key is published (or at least will ## Dual purpose: training wheels The validator has a 2nd purpose while Miden is maturing. To prevent private state from being lost, and to guard -from potential bugs in the VM/cryptography primitives, Miden will launch with training wheels. Notably, we will +from potential bugs in the VM/cryptography primitives, Miden will launch with training wheels. Notably, we require users to _include_ the private input data along with their transactions. This means users will have privacy -on the _network_ but not from the network operator. +on the _network_ but not from the validator set. As part of the transaction submission process, each transaction, its proof, and private inputs, are sent to the validator, which re-executes the transaction, thereby verifying it and its proof are correct. This also lets us store the private data @@ -30,7 +30,7 @@ Once verified, the block is signed and returned to the sender. In addition to its per-validator signing key, every validator is provisioned with the _same_ shared transaction encryption keypair, an Ed25519 key that miden-crypto uses for X25519 key -agreement in its IES scheme. Clients will use it to encrypt the private transaction inputs they +agreement in its IES scheme. Clients use it to encrypt the private transaction inputs they submit, so that any validator in the set can decrypt them. The `GetTransactionEncryptionKey` endpoint returns the shared public key together with an IES @@ -44,3 +44,16 @@ be served through an untrusted RPC. This scheme does not protect the inputs from parties holding the shared secret and has no forward secrecy. It is the first phase of the transaction input encryption design: later phases move the key material to threshold and TEE-managed setups. + +### Submission path + +`SubmitProvenTransaction` carries a `SealedTransactionInputs` envelope: a `key_id` in the clear plus +the ciphertext of a serialized `SealedMessage`. The validator rebuilds the associated data from its +_own_ scheme, key id and genesis commitment, plus the transaction id it parses from the accompanying +plaintext `ProvenTransaction`. Nothing the submitter controls enters the associated data, so a +mismatched `key_id` cannot influence which key is tried: it only lets the validator answer +`failed_precondition` ("re-fetch the key") instead of an indistinguishable authentication failure. + +The unseal happens before the serve lock is taken, so a slow or hung decrypt backend cannot starve +the exclusive lock that a backup block subscription needs. The cost is that an already-validated +resubmission pays for the unseal before being short-circuited. diff --git a/packaging/validator/miden-validator.service b/packaging/validator/miden-validator.service index 97247520c7..8d7d9c66c4 100644 --- a/packaging/validator/miden-validator.service +++ b/packaging/validator/miden-validator.service @@ -8,6 +8,7 @@ WantedBy=multi-user.target [Service] Type=exec Environment="OTEL_SERVICE_NAME=miden-validator" +EnvironmentFile=-/etc/opt/miden-validator/env ExecStart=/usr/bin/miden-validator start WorkingDirectory=/opt/miden-validator User=miden-validator diff --git a/proto/proto/types/transaction.proto b/proto/proto/types/transaction.proto index b4e6d5c782..bed60f80f4 100644 --- a/proto/proto/types/transaction.proto +++ b/proto/proto/types/transaction.proto @@ -10,35 +10,50 @@ import "types/primitives.proto"; // A proven transaction. // -// Note that we currently require full transaction transparency for the network operator. -// This is a temporary measure while Miden stabilizes its protocol and proof systems. To -// this end, a transaction submission includes its **private** inputs which the operator -// can use to verify the transaction execution and proofs are correct. +// Note that we currently require full transaction transparency for the validator set. This is a +// temporary measure while Miden stabilizes its protocol and proof systems. To this end, a +// transaction submission includes its **private** inputs, which a validator uses to verify that +// the transaction execution and proofs are correct. // -// This means the transaction is _not_ private wrt the network operator (but it is still -// private onchain). This requirement will be lifted as Miden matures. +// Those inputs are sealed against the validator set's shared transaction encryption key, so the +// nodes that relay a submission cannot read them: only a validator can. Submissions carrying +// unsealed inputs are rejected. This requirement will be lifted as Miden matures. message ProvenTransaction { // The transaction proof. // // Encoded using [miden_protocol::transaction::ProvenTransaction::to_bytes]. bytes transaction = 1; - // The private inputs used for the transaction proof. - // - // Encoded using [miden_protocol::transaction::TransactionInputs::to_bytes]. + + // The sealed private inputs used for the transaction proof. // // Transactions missing this field will be rejected as per the message description. - optional bytes transaction_inputs = 2; + SealedTransactionInputs sealed_transaction_inputs = 2; +} + +// Transaction inputs sealed against the validator set's shared transaction encryption key. +// +// Obtain the key with `GetTransactionEncryptionKey` before submitting. +message SealedTransactionInputs { + // Opaque identifier of the encryption key the inputs were sealed against, copied from + // [TransactionEncryptionKey.key_id]. + bytes key_id = 1; + + // The transaction inputs sealed with [miden_protocol::crypto::ies::SealingKey], encoded using + // the [miden_protocol::crypto::utils::Serializable] implementation for + // [miden_protocol::crypto::ies::SealedMessage]. + bytes ciphertext = 2; } // A proven batch of transactions. // -// Note that we currently require full transaction transparency for the network operator. -// This is a temporary measure while Miden stabilizes its protocol and proof systems. To -// this end, each transaction includes its **private** inputs which the operator can use -// to verify the transaction execution and proofs are correct. +// Note that we currently require full transaction transparency for the validator set. This is a +// temporary measure while Miden stabilizes its protocol and proof systems. To this end, each +// transaction includes its **private** inputs, which a validator uses to verify that the +// transaction execution and proofs are correct. // -// This means the transaction is _not_ private wrt the network operator (but it is still -// private onchain). This requirement will be lifted as Miden matures. +// Those inputs are sealed against the validator set's shared transaction encryption key, so the +// nodes that relay a submission cannot read them: only a validator can. Batches carrying unsealed +// inputs are rejected. This requirement will be lifted as Miden matures. // // In addition, in order to verify the batch itself, we also require the proposed batch. message TransactionBatch { @@ -52,14 +67,15 @@ message TransactionBatch { // // Batches missing this field will be rejected as per the message description. optional bytes proposed_batch = 2; - // The transaction inputs for each transaction in the batch. - // - // Must match the transaction ordering in the batch. + + // The sealed transaction inputs for each transaction in the batch. // - // Encoded using [miden_protocol::transaction::TransactionInputs::to_bytes]. + // Must match the transaction ordering in the batch. Each entry is sealed independently against + // its own transaction's id, because the batch is fanned out into one validator submission per + // transaction. // // Batch will be rejected if any transaction's input is missing as per the method description. - repeated bytes transaction_inputs = 3; + repeated SealedTransactionInputs sealed_transaction_inputs = 3; } // IES scheme used for transaction input encryption.