diff --git a/Cargo.lock b/Cargo.lock index f2652ed8d5..67787e3393 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2994,6 +2994,7 @@ dependencies = [ "anyhow", "clap", "fs-err", + "hex", "miden-node-proto", "miden-node-utils", "miden-protocol", @@ -3356,6 +3357,7 @@ name = "miden-node-proto" version = "0.16.0-alpha.2" dependencies = [ "anyhow", + "assert_matches", "build-rs", "codegen", "fs-err", @@ -3370,6 +3372,7 @@ dependencies = [ "proptest", "prost", "prost-types", + "rand 0.10.2", "thiserror 2.0.19", "tonic", "tonic-prost", diff --git a/bin/benchmark/Cargo.toml b/bin/benchmark/Cargo.toml index 89cc234df1..4ff2423211 100644 --- a/bin/benchmark/Cargo.toml +++ b/bin/benchmark/Cargo.toml @@ -20,6 +20,7 @@ workspace = true anyhow = { workspace = true } clap = { features = ["env", "string"], workspace = true } fs-err = { workspace = true } +hex = { workspace = true } miden-node-proto = { workspace = true } miden-node-utils = { workspace = true } miden-protocol = { features = ["std", "testing"], workspace = true } diff --git a/bin/benchmark/README.md b/bin/benchmark/README.md index 779be47fd3..2233bb40a4 100644 --- a/bin/benchmark/README.md +++ b/bin/benchmark/README.md @@ -47,11 +47,15 @@ Writes the bundle to `./benchmark-proofs/`: ```sh miden-benchmark run-benchmark \ - --rpc-url http://127.0.0.1:57291 \ - --concurrency 32 \ - --wait-blocks 3 + --rpc-url http://127.0.0.1:57291 \ + --validator-signing-public-key \ + --concurrency 32 \ + --wait-blocks 3 ``` +The signing public key must match the validator key that signs transaction encryption key attestations. The benchmark +will not submit transactions unless it can verify the advertised encryption key. + Mints go in sequentially, then consumes with the requested concurrency, then the run waits `--wait-blocks` blocks before scanning for inclusion. Per-phase ack rate, RPC latency percentiles, inclusion rate, and inclusion TPS are printed at the end. diff --git a/bin/benchmark/src/main.rs b/bin/benchmark/src/main.rs index 158088985d..1fba335bcd 100644 --- a/bin/benchmark/src/main.rs +++ b/bin/benchmark/src/main.rs @@ -11,8 +11,15 @@ use std::time::Duration; use anyhow::{Context, Result}; use clap::{Parser, Subcommand}; use miden_node_proto::clients::{Builder, RpcClient}; +use miden_node_proto::domain::encryption::{ + TransactionInputsSealer, + TrustedTransactionEncryptionState, + verify_transaction_encryption_key, +}; use miden_node_proto::generated::rpc::BlockHeaderByNumberRequest; +use miden_protocol::Word; use miden_protocol::block::{BlockHeader, BlockNumber}; +use miden_protocol::crypto::dsa::ecdsa_k256_keccak::PublicKey as ValidatorPublicKey; use miden_protocol::utils::serde::{Deserializable, Serializable}; use url::Url; @@ -80,6 +87,10 @@ pub enum Command { /// many blocks to fully include. #[arg(long, default_value_t = 30)] wait_blocks: u32, + /// Hex-encoded validator signing public key trusted to attest the transaction encryption + /// key. + #[arg(long)] + validator_signing_public_key: String, }, } @@ -104,8 +115,16 @@ impl Cli { concurrency, connections, wait_blocks, + validator_signing_public_key, } => { - submit::run(rpc_url, concurrency, connections, wait_blocks).await; + submit::run( + rpc_url, + concurrency, + connections, + wait_blocks, + validator_signing_public_key, + ) + .await; }, } } @@ -119,7 +138,7 @@ impl Cli { async fn build_rpc_client( rpc_url: &Url, timeout: Duration, - genesis: Option, + genesis: Option, ) -> Result { let use_tls = rpc_url.scheme() == "https"; @@ -141,9 +160,9 @@ async fn build_rpc_client( .context("Failed to connect to RPC server") } -/// 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 { +/// Discover the genesis commitment 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 { let mut rpc = build_rpc_client(rpc_url, timeout, None) .await .context("Failed to create RPC client for genesis discovery")?; @@ -161,7 +180,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 @@ -179,18 +198,35 @@ 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> { + trusted_validator_signing_key: ValidatorPublicKey, +) -> Result<(Vec, TransactionInputsSealer)> { let size = size.max(1); let genesis = discover_genesis(rpc_url, timeout).await?; 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)).await?); } - Ok(pool) + let key = pool[0] + .clone() + .get_transaction_encryption_key(()) + .await + .context("Failed to fetch the transaction encryption key")? + .into_inner(); + let trusted_keys = [trusted_validator_signing_key]; + let verified = verify_transaction_encryption_key( + key, + TrustedTransactionEncryptionState::new(genesis, &trusted_keys), + ) + .context("Untrusted transaction encryption key")?; + + Ok((pool, TransactionInputsSealer::new(verified))) } pub(crate) fn get_genesis_header_request() -> BlockHeaderByNumberRequest { diff --git a/bin/benchmark/src/submit.rs b/bin/benchmark/src/submit.rs index ab915d4f97..6b3744e8f1 100644 --- a/bin/benchmark/src/submit.rs +++ b/bin/benchmark/src/submit.rs @@ -18,9 +18,11 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{Duration, Instant, SystemTime}; use miden_node_proto::clients::RpcClient; +use miden_node_proto::domain::encryption::TransactionInputsSealer; use miden_node_proto::generated as proto; +use miden_protocol::crypto::dsa::ecdsa_k256_keccak::PublicKey as ValidatorPublicKey; use miden_protocol::transaction::{ProvenTransaction, TransactionId}; -use miden_protocol::utils::serde::Serializable; +use miden_protocol::utils::serde::{Deserializable, Serializable}; use tokio::sync::Semaphore; use url::Url; @@ -31,7 +33,13 @@ use crate::{PROOFS_DIR, create_genesis_aware_rpc_client_pool, read_from_file}; // ORCHESTRATOR // ================================================================================================ -pub(crate) async fn run(rpc_url: Url, concurrency: usize, connections: usize, wait_blocks: u32) { +pub(crate) async fn run( + rpc_url: Url, + concurrency: usize, + connections: usize, + wait_blocks: u32, + validator_signing_public_key: String, +) { let in_dir = PathBuf::from(PROOFS_DIR); println!("Loading mint txs from {}", in_dir.join("mint_txs.bin").display()); @@ -49,10 +57,21 @@ 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 trusted_validator_signing_key = ValidatorPublicKey::read_from_bytes( + &hex::decode(validator_signing_public_key) + .expect("validator signing public key must be a hex-encoded K256 public key"), + ) + .expect("validator signing public key must be a valid K256 public key"); + let (pool, sealer) = create_genesis_aware_rpc_client_pool( + &rpc_url, + Duration::from_secs(30), + connections, + trusted_validator_signing_key, + ) + .await + .expect("failed to create RPC client pool"); let pool = Arc::new(pool); + let sealer = Arc::new(sealer); let h_start = current_block_height(pool[0].clone()).await; println!("Chain height at start: {h_start}"); @@ -62,7 +81,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 +89,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 +162,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 +183,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 +237,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/README.md b/bin/network-monitor/README.md index e02194b2fa..485544d1ff 100644 --- a/bin/network-monitor/README.md +++ b/bin/network-monitor/README.md @@ -24,6 +24,10 @@ configured. The monitor is an observer and test client, not a node component required for block production. Its network transaction checks create fresh in-memory accounts on startup and do not persist account state to disk. +Network transaction checks also require `MIDEN_MONITOR_VALIDATOR_SIGNING_PUBLIC_KEY`. It must contain the hex-encoded +validator key that signs transaction encryption key attestations. The monitor will not submit a transaction unless it +can verify the advertised encryption key. + Use the binary help output for the current command and configuration surface. The help output is the source of truth for flags and environment variables. diff --git a/bin/network-monitor/src/config.rs b/bin/network-monitor/src/config.rs index 91b7722a4e..6912f73c48 100644 --- a/bin/network-monitor/src/config.rs +++ b/bin/network-monitor/src/config.rs @@ -5,7 +5,10 @@ use std::time::Duration; +use anyhow::{Context, Result}; use clap::Parser; +use miden_protocol::crypto::dsa::ecdsa_k256_keccak::PublicKey as ValidatorPublicKey; +use miden_protocol::utils::serde::Deserializable; use url::Url; // MONITOR CONFIGURATION CONSTANTS @@ -108,6 +111,16 @@ pub struct MonitorConfig { )] pub disable_ntx_service: bool, + /// Hex-encoded validator signing public key trusted to attest the transaction encryption key. + /// + /// Required when network transaction checks are enabled. + #[arg( + long = "validator-signing-public-key", + env = "MIDEN_MONITOR_VALIDATOR_SIGNING_PUBLIC_KEY", + value_name = "HEX" + )] + pub validator_signing_public_key: Option, + /// The interval at which to send the increment counter transaction. #[arg( long = "counter-increment-interval", @@ -187,3 +200,16 @@ pub struct MonitorConfig { )] pub stale_chain_tip_threshold: Duration, } + +impl MonitorConfig { + /// Decodes the validator signing key required by transaction submission checks. + pub fn trusted_validator_signing_key(&self) -> Result { + let encoded = self.validator_signing_public_key.as_deref().context( + "--validator-signing-public-key is required when network transaction checks are enabled", + )?; + let bytes = + hex::decode(encoded).context("validator signing public key must be hex encoded")?; + ValidatorPublicKey::read_from_bytes(&bytes) + .context("validator signing public key must be a valid K256 public key") + } +} diff --git a/bin/network-monitor/src/counter.rs b/bin/network-monitor/src/counter.rs index a4443dd9a7..39773c36c3 100644 --- a/bin/network-monitor/src/counter.rs +++ b/bin/network-monitor/src/counter.rs @@ -10,7 +10,6 @@ use std::time::{Duration, Instant}; use anyhow::{Context, Result}; use miden_node_proto::clients::RpcClient; use miden_node_proto::generated::rpc::BlockHeaderByNumberRequest; -use miden_node_proto::generated::transaction::ProvenTransaction; use miden_node_utils::spawn::spawn_blocking_in_current_span; use miden_node_utils::tracing::miden_instrument; use miden_protocol::account::auth::AuthSecretKey; @@ -51,7 +50,9 @@ use crate::config::MonitorConfig; use crate::deploy::counter::COUNTER_SLOT_NAME; use crate::deploy::wallet::WALLET_COUNTER_SLOT_NAME; use crate::deploy::{ + DeployedMonitorAccounts, MonitorDataStore, + TransactionSubmissionClient, create_and_deploy_accounts, create_genesis_aware_rpc_client, }; @@ -164,6 +165,8 @@ 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, + /// Shared client for attestation verification, sealing, and transaction submission. + submission_client: TransactionSubmissionClient, } impl IncrementService { @@ -173,18 +176,20 @@ impl IncrementService { pub async fn new( config: MonitorConfig, - wallet_account: Account, - secret_key: SecretKey, - counter_account: Account, + accounts: DeployedMonitorAccounts, prover: LocalTransactionProver, + submission_client: TransactionSubmissionClient, 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 (tx, details) = - setup_increment_task(wallet_account, secret_key, counter_account, &mut rpc_client) - .await?; + let mut rpc_client = submission_client.rpc_client(); + let (tx, details) = setup_increment_task( + accounts.wallet, + accounts.secret_key, + accounts.counter, + &mut rpc_client, + ) + .await?; Ok(Self { config, rpc_client, @@ -194,6 +199,7 @@ impl IncrementService { details, latency_state, accounts_sender, + submission_client, }) } @@ -284,18 +290,21 @@ impl IncrementService { err, )] async fn try_regenerate_accounts(&mut self) -> Result<()> { - let (wallet_account, secret_key, counter_account) = - create_and_deploy_accounts(&self.config.rpc_url, &self.prover) - .await - .context("failed to regenerate accounts")?; + let accounts = create_and_deploy_accounts(&self.submission_client, &self.prover) + .await + .context("failed to regenerate accounts")?; let tracked = TrackedAccounts { - wallet: wallet_account.clone(), - counter: counter_account.clone(), + wallet: accounts.wallet.clone(), + counter: accounts.counter.clone(), }; - let (tx, details) = - setup_increment_task(wallet_account, secret_key, counter_account, &mut self.rpc_client) - .await?; + let (tx, details) = setup_increment_task( + accounts.wallet, + accounts.secret_key, + accounts.counter, + &mut self.rpc_client, + ) + .await?; self.tx = tx; self.details = details; @@ -368,19 +377,7 @@ impl IncrementService { .await .context("counter increment task failed")??; - let request = ProvenTransaction { - transaction: proven_tx.to_bytes(), - transaction_inputs: Some(tx_inputs), - }; - - let block_height: BlockNumber = self - .rpc_client - .submit_proven_tx(request) - .await - .context("Failed to submit proven transaction to RPC")? - .into_inner() - .block_num - .into(); + let block_height = self.submission_client.submit(&proven_tx, &tx_inputs).await?; info!(target: LOG_TARGET, "Submitted proven transaction to RPC"); @@ -487,7 +484,7 @@ impl CounterTrackingService { accounts_receiver: watch::Receiver, latency_state: Arc>, ) -> Result { - let mut rpc_client = + let (mut rpc_client, _) = create_genesis_aware_rpc_client(&config.rpc_url, config.request_timeout).await?; let TrackedAccounts { wallet: wallet_account, diff --git a/bin/network-monitor/src/deploy/mod.rs b/bin/network-monitor/src/deploy/mod.rs index c9d9720404..7c477f1054 100644 --- a/bin/network-monitor/src/deploy/mod.rs +++ b/bin/network-monitor/src/deploy/mod.rs @@ -3,19 +3,28 @@ //! This module contains functionality for deploying Miden accounts to the network. use std::collections::{BTreeSet, HashMap}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; 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::{ + TransactionInputsSealer, + TrustedTransactionEncryptionState, + verify_transaction_encryption_key, +}; use miden_node_proto::generated::rpc::BlockHeaderByNumberRequest; -use miden_node_proto::generated::transaction::ProvenTransaction; +use miden_node_proto::generated::transaction::ProvenTransaction as ProtoProvenTransaction; +use miden_node_utils::retry; use miden_node_utils::spawn::spawn_blocking_in_current_span; use miden_node_utils::tracing::miden_instrument; use miden_protocol::Word; use miden_protocol::account::{Account, AccountId, PartialAccount, StorageMapKey}; use miden_protocol::asset::{AssetId, AssetWitness}; use miden_protocol::block::{BlockHeader, BlockNumber}; +use miden_protocol::crypto::dsa::ecdsa_k256_keccak::PublicKey as ValidatorPublicKey; use miden_protocol::crypto::dsa::falcon512_poseidon2::SecretKey; use miden_protocol::crypto::merkle::mmr::{MmrPeaks, PartialMmr}; use miden_protocol::note::{NoteScript, NoteScriptRoot}; @@ -24,6 +33,7 @@ use miden_protocol::transaction::{ ExecutedTransaction, InputNotes, PartialBlockchain, + ProvenTransaction, TransactionArgs, TransactionInputs, }; @@ -38,6 +48,7 @@ use miden_tx::{ TransactionExecutor, TransactionMastStore, }; +use tokio::sync::Mutex; use url::Url; use crate::deploy::counter::create_counter_account; @@ -47,6 +58,129 @@ use crate::{COMPONENT, LOG_TARGET}; pub mod counter; pub mod wallet; +/// Monitor accounts and signing key created as one deployment unit. +pub struct DeployedMonitorAccounts { + pub wallet: Account, + pub secret_key: SecretKey, + pub counter: Account, +} + +/// RPC client and verified transaction-input sealer shared by monitor submission workflows. +#[derive(Clone)] +pub struct TransactionSubmissionClient { + rpc_client: RpcClient, + genesis_commitment: Word, + trusted_validator_signing_keys: Arc<[ValidatorPublicKey]>, + sealer: Arc>>, +} + +impl TransactionSubmissionClient { + /// Connects to RPC and pins the validator key trusted for encryption-key attestations. + pub async fn connect( + rpc_url: &Url, + timeout: Duration, + trusted_validator_signing_key: ValidatorPublicKey, + ) -> Result { + let (rpc_client, genesis_commitment) = + create_genesis_aware_rpc_client(rpc_url, timeout).await?; + let client = Self { + rpc_client, + genesis_commitment, + trusted_validator_signing_keys: Arc::from([trusted_validator_signing_key]), + sealer: Arc::new(Mutex::new(None)), + }; + client.sealer().await?; + Ok(client) + } + + /// Returns a clone of the underlying RPC client for read operations. + pub fn rpc_client(&self) -> RpcClient { + self.rpc_client.clone() + } + + /// Returns the cached verified sealer, fetching and checking the attested key on first use. + async fn sealer(&self) -> Result { + if let Some(sealer) = self.sealer.lock().await.clone() { + return Ok(sealer); + } + + let key = self + .rpc_client + .clone() + .get_transaction_encryption_key(()) + .await + .context("Failed to fetch the transaction encryption key")? + .into_inner(); + let verified = verify_transaction_encryption_key( + key, + TrustedTransactionEncryptionState::new( + self.genesis_commitment, + &self.trusted_validator_signing_keys, + ), + ) + .context("Untrusted transaction encryption key")?; + let sealer = TransactionInputsSealer::new(verified); + + let mut cached = self.sealer.lock().await; + if let Some(sealer) = cached.clone() { + return Ok(sealer); + } + *cached = Some(sealer.clone()); + Ok(sealer) + } + + /// Seals and submits one proven transaction, retrying once with a fresh key when needed. + pub async fn submit( + &self, + proven_tx: &ProvenTransaction, + transaction_inputs: &[u8], + ) -> Result { + let transaction = proven_tx.to_bytes(); + let tx_id = proven_tx.id(); + let stale_key = AtomicBool::new(false); + + let result = (|| { + let transaction = transaction.clone(); + async { + if stale_key.swap(false, Ordering::Relaxed) { + *self.sealer.lock().await = None; + } + + let sealed = self + .sealer() + .await? + .seal(tx_id, transaction_inputs) + .context("Failed to seal the transaction inputs")?; + self.rpc_client + .clone() + .submit_proven_tx(ProtoProvenTransaction { + transaction, + sealed_transaction_inputs: Some(sealed), + }) + .await + .context("Failed to submit proven transaction to RPC") + } + }) + .retry(retry::constant(Duration::ZERO, Some(1))) + .when(|err: &anyhow::Error| { + err.downcast_ref::() + .is_some_and(|status| status.code() == tonic::Code::FailedPrecondition) + }) + .notify(|status: &anyhow::Error, _| { + stale_key.store(true, Ordering::Relaxed); + tracing::warn!( + target: COMPONENT, + %tx_id, + err = %status, + "Transaction inputs rejected as stale, refreshing the encryption key and retrying", + ); + }) + .await; + + Ok(result?.into_inner().block_num.into()) + } +} + /// Backoff schedule applied to the genesis-discovery RPC handshake. /// /// At startup the monitor may come up before the node's RPC endpoint is accepting connections, so @@ -78,7 +212,7 @@ fn genesis_discovery_backoff() -> ExponentialBuilder { pub async fn create_genesis_aware_rpc_client( rpc_url: &Url, timeout: Duration, -) -> Result { +) -> Result<(RpcClient, Word)> { (|| async { // First, create a temporary client without genesis metadata to discover the genesis block // header and its commitment. @@ -111,8 +245,6 @@ pub async fn create_genesis_aware_rpc_client( let genesis_header: BlockHeader = genesis_block_header.try_into().context("Failed to convert block header")?; let genesis_commitment = genesis_header.commitment(); - let genesis = genesis_commitment.to_hex(); - // Rebuild the client, this time including the required genesis metadata so that write RPCs // like SubmitProvenTx are accepted by the node. let rpc_client = Builder::new(rpc_url.clone()) @@ -120,13 +252,13 @@ pub async fn create_genesis_aware_rpc_client( .context("Failed to configure TLS for RPC client")? .with_timeout(timeout) .without_metadata_version() - .with_metadata_genesis(genesis) + .with_metadata_genesis(genesis_commitment) .without_otel_context_injection() .connect() .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| { @@ -146,18 +278,22 @@ pub async fn create_genesis_aware_rpc_client( /// (e.g., after a network reset) and re-syncing from the RPC is not sufficient. The accounts /// are never persisted to disk; the monitor re-creates them on every restart. pub async fn create_and_deploy_accounts( - rpc_url: &Url, + submission_client: &TransactionSubmissionClient, prover: &LocalTransactionProver, -) -> Result<(Account, SecretKey, Account)> { +) -> Result { tracing::info!(target: LOG_TARGET, "Creating fresh monitor accounts"); let (wallet_account, secret_key) = create_wallet_account()?; let counter_account = create_counter_account(wallet_account.id())?; - deploy_counter_account(&counter_account, rpc_url, prover).await?; + deploy_counter_account(&counter_account, submission_client, prover).await?; tracing::info!(target: LOG_TARGET, "Successfully created and deployed accounts"); - Ok((wallet_account, secret_key, counter_account)) + Ok(DeployedMonitorAccounts { + wallet: wallet_account, + secret_key, + counter: counter_account, + }) } /// Execute the counter account's genesis (creation) transaction in-memory. @@ -221,7 +357,8 @@ pub async fn build_probe_transaction_inputs(rpc_url: &Url) -> Result Result 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 = submission_client.rpc_client(); let executed_tx = execute_counter_genesis_tx(counter_account, &mut rpc_client).await?; @@ -252,15 +388,7 @@ pub async fn deploy_counter_account( .context("prover task panicked")? .context("Failed to prove transaction")?; - let request = ProvenTransaction { - transaction: proven_tx.to_bytes(), - transaction_inputs: Some(transaction_inputs), - }; - - rpc_client - .submit_proven_tx(request) - .await - .context("Failed to submit proven transaction to RPC")?; + submission_client.submit(&proven_tx, &transaction_inputs).await?; Ok(()) } diff --git a/bin/network-monitor/src/monitor/tasks.rs b/bin/network-monitor/src/monitor/tasks.rs index 0f76757066..91717be39a 100644 --- a/bin/network-monitor/src/monitor/tasks.rs +++ b/bin/network-monitor/src/monitor/tasks.rs @@ -15,7 +15,7 @@ use tracing::{debug, warn}; use crate::LOG_TARGET; use crate::config::MonitorConfig; use crate::counter::{CounterTrackingService, IncrementService, LatencyState, TrackedAccounts}; -use crate::deploy::create_and_deploy_accounts; +use crate::deploy::{TransactionSubmissionClient, create_and_deploy_accounts}; use crate::explorer::ExplorerService; use crate::faucet::FaucetService; use crate::frontend::{ServerState, serve}; @@ -253,22 +253,27 @@ async fn bootstrap_ntx( config: &MonitorConfig, ) -> Result<(IncrementService, CounterTrackingService)> { let prover = LocalTransactionProver::default(); - let (wallet_account, secret_key, counter_account) = - create_and_deploy_accounts(&config.rpc_url, &prover).await?; + let trusted_validator_signing_key = config.trusted_validator_signing_key()?; + let submission_client = TransactionSubmissionClient::connect( + &config.rpc_url, + config.request_timeout, + trusted_validator_signing_key, + ) + .await?; + let accounts = create_and_deploy_accounts(&submission_client, &prover).await?; let (accounts_tx, accounts_rx) = watch::channel(TrackedAccounts { - wallet: wallet_account.clone(), - counter: counter_account.clone(), + wallet: accounts.wallet.clone(), + counter: accounts.counter.clone(), }); let latency_state = Arc::new(Mutex::new(LatencyState::default())); let increment_svc = IncrementService::new( config.clone(), - wallet_account, - secret_key, - counter_account, + accounts, prover, + submission_client, accounts_tx, latency_state.clone(), ) diff --git a/bin/ntx-builder/src/actor/mod.rs b/bin/ntx-builder/src/actor/mod.rs index a3a07b7fbd..e5d3b917d6 100644 --- a/bin/ntx-builder/src/actor/mod.rs +++ b/bin/ntx-builder/src/actor/mod.rs @@ -151,6 +151,7 @@ impl AccountActorContext { let url = Url::parse("http://127.0.0.1:1").unwrap(); let block_header = mock_block_header(0_u32.into()); + let trusted_validator_signing_keys = block_header.validator_keys().as_keys().to_vec(); let chain_mmr = PartialMmr::from_peaks( MmrPeaks::new(Forest::new(0).expect("forest 0 is valid"), vec![]).unwrap(), ); @@ -163,6 +164,7 @@ impl AccountActorContext { rpc: RpcClient::new( url.clone(), miden_protocol::Word::default(), + trusted_validator_signing_keys, Duration::from_millis(100), Duration::from_secs(30), ) diff --git a/bin/ntx-builder/src/clients/rpc.rs b/bin/ntx-builder/src/clients/rpc.rs index 0e53933507..312d88cc56 100644 --- a/bin/ntx-builder/src/clients/rpc.rs +++ b/bin/ntx-builder/src/clients/rpc.rs @@ -1,8 +1,10 @@ use std::collections::BTreeSet; -use miden_node_utils::tracing::miden_instrument; - +use std::sync::atomic::{AtomicBool, Ordering}; +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 +12,11 @@ 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::{ + TransactionInputsSealer, + TrustedTransactionEncryptionState, + verify_transaction_encryption_key, +}; 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; @@ -17,6 +24,7 @@ use miden_node_proto::generated::rpc::{BlockSubscriptionRequest, BlockSubscripti use miden_node_proto::generated::{self as proto}; use miden_node_utils::ErrorReport; use miden_node_utils::retry::{self, Retryable}; +use miden_node_utils::tracing::miden_instrument; use miden_protocol::Word; use miden_protocol::account::{ AccountCode, @@ -29,6 +37,7 @@ use miden_protocol::account::{ }; use miden_protocol::asset::{Asset, AssetVault, AssetId, AssetWitness, PartialVault}; use miden_protocol::block::{BlockNumber, SignedBlock}; +use miden_protocol::crypto::dsa::ecdsa_k256_keccak::PublicKey as ValidatorPublicKey; use miden_protocol::note::NoteScript; use miden_protocol::transaction::{AccountInputs, ProvenTransaction, TransactionInputs}; use miden_protocol::utils::serde::{Deserializable, Serializable}; @@ -70,6 +79,13 @@ 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>>, + /// Validator signing keys read from the genesis block at bootstrap. + trusted_validator_signing_keys: Arc<[ValidatorPublicKey]>, } impl RpcClient { @@ -80,10 +96,18 @@ impl RpcClient { pub fn new( rpc_url: Url, genesis_commitment: Word, + trusted_validator_signing_keys: Vec, backoff_initial: Duration, backoff_max: Duration, ) -> anyhow::Result { - Self::new_with_auth(rpc_url, None, genesis_commitment, backoff_initial, backoff_max) + Self::new_with_auth( + rpc_url, + None, + genesis_commitment, + trusted_validator_signing_keys, + backoff_initial, + backoff_max, + ) } /// Creates a new client with an optional metadata header for internal RPC authentication. @@ -94,6 +118,7 @@ impl RpcClient { rpc_url: Url, rpc_auth_header_value: Option, genesis_commitment: Word, + trusted_validator_signing_keys: Vec, backoff_initial: Duration, backoff_max: Duration, ) -> anyhow::Result { @@ -103,7 +128,7 @@ impl RpcClient { .with_tls()? .without_timeout() .without_metadata_version() - .with_metadata_genesis(genesis_commitment.to_hex()); + .with_metadata_genesis(genesis_commitment); let builder = match rpc_auth_header_value { Some(value) => builder.with_auth_header_value(value), None => builder.without_auth_header(), @@ -112,7 +137,42 @@ 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)), + trusted_validator_signing_keys: trusted_validator_signing_keys.into(), + }) + } + + /// Returns a sealer for transaction inputs, fetching the encryption key if the cache is empty. + pub(crate) async fn sealer(&self) -> Result { + if let Some(sealer) = self.sealer.read().await.clone() { + return Ok(sealer); + } + + let key = self.inner.clone().get_transaction_encryption_key(()).await?.into_inner(); + let verified = verify_transaction_encryption_key( + key, + TrustedTransactionEncryptionState::new( + self.genesis_commitment, + &self.trusted_validator_signing_keys, + ), + ) + .map_err(|err| { + Status::failed_precondition( + err.as_report_context("Untrusted transaction encryption key"), + ) + })?; + let sealer = TransactionInputsSealer::new(verified); + + let mut cached = self.sealer.write().await; + if let Some(sealer) = cached.clone() { + return Ok(sealer); + } + *cached = Some(sealer.clone()); + Ok(sealer) } /// Opens a committed-block subscription starting at `block_from`, retrying indefinitely with @@ -270,14 +330,48 @@ impl RpcClient { proven_tx: &ProvenTransaction, tx_inputs: &TransactionInputs, ) -> Result<(), Status> { - let request = proto::transaction::ProvenTransaction { - transaction: proven_tx.to_bytes(), - transaction_inputs: Some(tx_inputs.to_bytes()), - }; - - self.inner.clone().submit_proven_tx(request).await?; + let transaction = proven_tx.to_bytes(); + let transaction_inputs = tx_inputs.to_bytes(); + let tx_id = proven_tx.id(); + let stale_key = AtomicBool::new(false); + + (|| { + let mut client = self.inner.clone(); + let transaction = transaction.clone(); + let transaction_inputs = transaction_inputs.clone(); + let stale_key = &stale_key; + async move { + if stale_key.swap(false, Ordering::Relaxed) { + *self.sealer.write().await = None; + } - Ok(()) + let sealer = self.sealer().await?; + let sealed = sealer.seal(tx_id, &transaction_inputs).map_err(|err| { + Status::failed_precondition( + err.as_report_context("Failed to seal the transaction inputs"), + ) + })?; + client + .submit_proven_tx(proto::transaction::ProvenTransaction { + transaction, + sealed_transaction_inputs: Some(sealed), + }) + .await + } + }) + .retry(retry::constant(Duration::ZERO, Some(1))) + .when(|status: &Status| status.code() == tonic::Code::FailedPrecondition) + .notify(|status: &Status, _| { + stale_key.store(true, Ordering::Relaxed); + tracing::warn!( + target: COMPONENT, + %tx_id, + err = %status.message(), + "Transaction inputs rejected as stale, refreshing the encryption key and retrying", + ); + }) + .await + .map(|_| ()) } } diff --git a/bin/ntx-builder/src/db/migrations.rs b/bin/ntx-builder/src/db/migrations.rs index 6c45b29f18..fd63cb8d10 100644 --- a/bin/ntx-builder/src/db/migrations.rs +++ b/bin/ntx-builder/src/db/migrations.rs @@ -72,9 +72,10 @@ mod tests { use super::*; - const EXPECTED_SCHEMA_HASHES: [SchemaHash; 1] = [SchemaHash::from_hex( - "c631b773787903a3dd5ea4df5e7374119b3f02b35bacf14d11eacd8d8500e3d9", - )]; + const EXPECTED_SCHEMA_HASHES: [SchemaHash; 2] = [ + SchemaHash::from_hex("c631b773787903a3dd5ea4df5e7374119b3f02b35bacf14d11eacd8d8500e3d9"), + SchemaHash::from_hex("26b17298444f674b06327ae7289516fe75b59926741b1221ebf36735822d116a"), + ]; #[test] fn migration_schema_hashes_are_stable() -> Result<()> { diff --git a/bin/ntx-builder/src/db/migrations/002_genesis_validator_keys.sql b/bin/ntx-builder/src/db/migrations/002_genesis_validator_keys.sql new file mode 100644 index 0000000000..ebe45372d5 --- /dev/null +++ b/bin/ntx-builder/src/db/migrations/002_genesis_validator_keys.sql @@ -0,0 +1,4 @@ +-- Preserve the validator signing keys from genesis as the trust root for transaction encryption +-- key attestations. Existing databases must be re-bootstrapped because their genesis header was +-- not retained after the chain tip advanced. +ALTER TABLE chain_state ADD COLUMN genesis_validator_keys BLOB; diff --git a/bin/ntx-builder/src/db/mod.rs b/bin/ntx-builder/src/db/mod.rs index 61b7d69a99..9a2f39a838 100644 --- a/bin/ntx-builder/src/db/mod.rs +++ b/bin/ntx-builder/src/db/mod.rs @@ -6,7 +6,7 @@ use miden_node_db::DatabaseError; use miden_node_utils::tracing::miden_instrument; use miden_protocol::Word; use miden_protocol::account::AccountId; -use miden_protocol::block::{BlockHeader, BlockNumber, SignedBlock}; +use miden_protocol::block::{BlockHeader, BlockNumber, SignedBlock, ValidatorKeys}; use miden_protocol::crypto::merkle::mmr::PartialMmr; use miden_protocol::note::{NoteId, NoteScript, Nullifier}; // Only the test-only `upsert_account_for_test` helper names `TransactionId` directly. @@ -148,6 +148,13 @@ impl Db { .await } + /// Reads the validator signing keys persisted from the genesis header. + pub async fn get_genesis_validator_keys(&self) -> Result> { + self.inner + .query("get_genesis_validator_keys", queries::select_genesis_validator_keys) + .await + } + // BLOCK APPLICATION // ============================================================================================ @@ -435,8 +442,10 @@ mod tests { async fn bootstrap_seeds_genesis_chain_state() { let dir = tempfile::tempdir().expect("failed to create temp directory"); let db_path = dir.path().join("ntx-builder.sqlite3"); + let genesis = mock_genesis_block(); + let expected_validator_keys = genesis.header().validator_keys().clone(); - Db::bootstrap(db_path.clone(), &mock_genesis_block()) + Db::bootstrap(db_path.clone(), &genesis) .await .expect("bootstrap should succeed on a fresh database"); @@ -448,6 +457,13 @@ mod tests { .expect("chain state should be present after bootstrap"); assert_eq!(block_num, BlockNumber::GENESIS); + assert_eq!( + db.get_genesis_validator_keys() + .await + .expect("query should succeed") + .expect("genesis validator keys should be present"), + expected_validator_keys, + ); } #[tokio::test] diff --git a/bin/ntx-builder/src/db/models/queries/chain_state.rs b/bin/ntx-builder/src/db/models/queries/chain_state.rs index 91c61182f5..dcdbf440d8 100644 --- a/bin/ntx-builder/src/db/models/queries/chain_state.rs +++ b/bin/ntx-builder/src/db/models/queries/chain_state.rs @@ -3,7 +3,7 @@ use diesel::prelude::*; use miden_node_db::DatabaseError; use miden_protocol::Word; -use miden_protocol::block::{BlockHeader, BlockNumber}; +use miden_protocol::block::{BlockHeader, BlockNumber, ValidatorKeys}; use miden_protocol::crypto::merkle::mmr::PartialMmr; use miden_protocol::utils::serde::{Deserializable, Serializable}; @@ -23,6 +23,7 @@ pub struct ChainStateInsert { pub block_header: Vec, pub chain_mmr: Vec, pub genesis_commitment: Vec, + pub genesis_validator_keys: Option>, } #[derive(Debug, Clone, Queryable, Selectable)] @@ -91,6 +92,7 @@ pub fn insert_genesis_chain_state( block_header: conversions::block_header_to_bytes(genesis_block_header), chain_mmr: PartialMmr::default().to_bytes(), genesis_commitment: conversions::word_to_bytes(genesis_commitment), + genesis_validator_keys: Some(genesis_block_header.validator_keys().to_bytes()), }; diesel::insert_into(schema::chain_state::table).values(&row).execute(conn)?; Ok(()) @@ -117,6 +119,33 @@ pub fn select_genesis_commitment(conn: &mut SqliteConnection) -> Result Result, DatabaseError> { + let keys: Option> = schema::chain_state::table + .find(0i32) + .select(schema::chain_state::genesis_validator_keys) + .first(conn)?; + + keys.map(|keys| { + ValidatorKeys::read_from_bytes(&keys) + .map_err(|e| DatabaseError::deserialization("genesis validator keys", e)) + }) + .transpose() +} + /// Reads the singleton chain state row, returning the persisted block number, header, and chain /// MMR if any block has been applied locally. /// diff --git a/bin/ntx-builder/src/db/schema.rs b/bin/ntx-builder/src/db/schema.rs index 22600d0392..4431891346 100644 --- a/bin/ntx-builder/src/db/schema.rs +++ b/bin/ntx-builder/src/db/schema.rs @@ -15,6 +15,7 @@ diesel::table! { block_header -> Binary, chain_mmr -> Binary, genesis_commitment -> Binary, + genesis_validator_keys -> Nullable, } } diff --git a/bin/ntx-builder/src/lib.rs b/bin/ntx-builder/src/lib.rs index 7abaa5bb67..6a469815b2 100644 --- a/bin/ntx-builder/src/lib.rs +++ b/bin/ntx-builder/src/lib.rs @@ -381,22 +381,33 @@ impl NtxBuilderConfig { "failed to read genesis commitment; \ run `miden-ntx-builder bootstrap` first", )?; + let genesis_validator_keys = db + .get_genesis_validator_keys() + .await + .context("failed to read genesis validator keys")? + .context("genesis validator keys are missing; re-bootstrap the NTX builder database")?; + let trusted_validator_signing_keys = genesis_validator_keys.as_keys().to_vec(); let rpc = match self.rpc_auth_header.clone() { Some(rpc_auth_header_value) => RpcClient::new_with_auth( self.rpc_url.clone(), Some(rpc_auth_header_value), genesis_commitment, + trusted_validator_signing_keys, self.request_backoff_initial, self.request_backoff_max, ), None => RpcClient::new( self.rpc_url.clone(), genesis_commitment, + trusted_validator_signing_keys, self.request_backoff_initial, self.request_backoff_max, ), }?; + rpc.sealer() + .await + .context("failed to initialize the transaction inputs sealer")?; // The database is bootstrapped with the genesis block before startup (see // `miden-ntx-builder bootstrap`), so a persisted chain state is always present. Load it and diff --git a/bin/validator/src/lib.rs b/bin/validator/src/lib.rs index fce827980e..43d0c45285 100644 --- a/bin/validator/src/lib.rs +++ b/bin/validator/src/lib.rs @@ -9,11 +9,8 @@ pub use server::ValidatorServer; pub use signers::{ KmsSigner, LocalX25519TransactionInputDecrypter, - NextEncryptionKeyInfo, - TransactionEncryptionKeyInfo, TransactionInputDecrypter, ValidatorSigner, - attestation_commitment, decrypt_key_material, }; diff --git a/bin/validator/src/server/validator_service/get_transaction_encryption_key.rs b/bin/validator/src/server/validator_service/get_transaction_encryption_key.rs index d916eb4c18..26af98bc5f 100644 --- a/bin/validator/src/server/validator_service/get_transaction_encryption_key.rs +++ b/bin/validator/src/server/validator_service/get_transaction_encryption_key.rs @@ -33,8 +33,7 @@ impl grpc::server::validator_api::GetTransactionEncryptionKey for ValidatorServi // Built entirely from state fixed at construction, so the endpoint stays available while a // backup subscription holds the serve lock. Ok(grpc::transaction::TransactionEncryptionKey { - scheme: i32::try_from(self.encryption_key_info.scheme) - .expect("scheme identifier must fit in i32"), + scheme: self.encryption_key_info.scheme.as_i32(), key_id: self.encryption_key_info.key_id.clone(), public_key: self.encryption_key_info.public_key.clone(), attestations: vec![grpc::transaction::ValidatorKeyAttestation { @@ -43,7 +42,7 @@ impl grpc::server::validator_api::GetTransactionEncryptionKey for ValidatorServi }], next_key: self.encryption_key_info.next_key.as_ref().map(|next| { grpc::transaction::NextTransactionEncryptionKey { - scheme: i32::try_from(next.scheme).expect("scheme identifier must fit in i32"), + scheme: next.scheme.as_i32(), key_id: next.key_id.clone(), public_key: next.public_key.clone(), rotation_block_num: next.rotation_block_num, diff --git a/bin/validator/src/server/validator_service/mod.rs b/bin/validator/src/server/validator_service/mod.rs index d7aaea10e4..83a6a11fdf 100644 --- a/bin/validator/src/server/validator_service/mod.rs +++ b/bin/validator/src/server/validator_service/mod.rs @@ -3,8 +3,10 @@ use std::sync::atomic::AtomicU64; use miden_node_db::DatabaseError; use miden_node_db::sqlite::Database; +use miden_node_proto::domain::encryption::TransactionEncryptionKeyInfo; 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, @@ -19,7 +21,6 @@ use miden_protocol::transaction::{TransactionHeader, TransactionId}; use tokio::sync::{Semaphore, watch}; use crate::db::{find_unvalidated_transactions, load_block_header, load_chain_tip}; -use crate::signers::TransactionEncryptionKeyInfo; use crate::{COMPONENT, TransactionInputDecrypter, ValidatorSigner}; #[cfg(test)] @@ -76,8 +77,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 @@ -145,6 +147,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..53df4e0696 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.as_u32(), + &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 b4ea16ec69..0d82ff5b11 100644 --- a/bin/validator/src/server/validator_service/tests.rs +++ b/bin/validator/src/server/validator_service/tests.rs @@ -1,20 +1,34 @@ use std::collections::BTreeMap; +use miden_node_proto::domain::encryption::{ + TransactionEncryptionScheme, + TrustedTransactionEncryptionState, + transaction_inputs_associated_data, + verify_transaction_encryption_key, +}; 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, ValidatorKeys}; -use miden_protocol::crypto::dsa::ecdsa_k256_keccak::{Signature, SigningKey}; +use miden_protocol::account::AccountUpdateDetails; +use miden_protocol::block::{BlockHeader, BlockInputs, BlockNumber, ProposedBlock, ValidatorKeys}; +use miden_protocol::crypto::dsa::ecdsa_k256_keccak::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}; use crate::db::{load_chain_tip, setup, upsert_block_header}; -use crate::signers::{NextEncryptionKeyInfo, attestation_commitment}; use crate::{LocalX25519TransactionInputDecrypter, TransactionInputDecrypter, ValidatorSigner}; // TEST HELPERS @@ -73,6 +87,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: proto::transaction::SealedTransactionInputs, + ) -> Result<(), tonic::Status> { + let request = tonic::Request::new(proto::transaction::ProvenTransaction { + transaction: tx.to_bytes(), + sealed_transaction_inputs: Some(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.as_u32(), + &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, @@ -201,6 +253,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 // ================================================================================================ @@ -761,15 +840,11 @@ async fn transaction_encryption_key_is_attested() { let response = tv.call_get_transaction_encryption_key().await; let info = test_decrypter().encryption_key().await.expect("key info should be available"); - let scheme = u32::try_from(response.scheme).expect("scheme must be non-negative"); + let scheme = TransactionEncryptionScheme::try_from(response.scheme).unwrap(); assert_eq!(scheme, info.scheme); assert_eq!(response.key_id, info.key_id); assert_eq!(response.public_key, info.public_key); - let commitment = - attestation_commitment(scheme, &response.key_id, genesis, &response.public_key, None); - assert_eq!(commitment, info.attestation_commitment(genesis)); - let [attestation] = response.attestations.as_slice() else { panic!("response must carry exactly the serving validator's attestation"); }; @@ -778,12 +853,13 @@ async fn transaction_encryption_key_is_attested() { tv.server.signer.public_key().to_bytes(), "attestation must identify the serving validator", ); - let signature = - Signature::read_from_bytes(&attestation.signature).expect("signature should deserialize"); - assert!( - signature.verify(commitment, &tv.server.signer.public_key()), - "attestation must verify against this validator's signing key", - ); + let trusted_keys = [tv.server.signer.public_key()]; + let verified = verify_transaction_encryption_key( + response, + TrustedTransactionEncryptionState::new(genesis, &trusted_keys), + ) + .expect("attestation must verify against this validator's signing key"); + assert_eq!(verified.info(), &info); } /// Two validators provisioned with the same shared encryption secret but distinct signing keys @@ -812,54 +888,40 @@ async fn tampered_attestation_fails_verification() { let tv = TestValidator::new().await; let genesis = tv.chain_tip.commitment(); let response = tv.call_get_transaction_encryption_key().await; - let signature = Signature::read_from_bytes(&response.attestations[0].signature).unwrap(); - let signing_key = tv.server.signer.public_key(); - let scheme = u32::try_from(response.scheme).expect("scheme must be non-negative"); - - let mut tampered_public_key = response.public_key.clone(); - tampered_public_key[0] ^= 0x01; - let mut tampered_key_id = response.key_id.clone(); - tampered_key_id[0] ^= 0x01; - // Moving a byte across the key id and public key boundary must also change the payload, which - // the length prefixes in the transcript guarantee. - let mut extended_key_id = response.key_id.clone(); - extended_key_id.push(response.public_key[0]); - let tampered_genesis = Word::try_from([9u64, 9, 9, 9]).unwrap(); - // Injecting a scheduled rotation into a response attested without one must also break the - // signature. - let injected_next_key = NextEncryptionKeyInfo { - scheme, + let trusted_keys = [tv.server.signer.public_key()]; + let trusted = TrustedTransactionEncryptionState::new(genesis, &trusted_keys); + + let mut changed_scheme = response.clone(); + changed_scheme.scheme += 1; + let mut changed_key_id = response.clone(); + changed_key_id.key_id[0] ^= 0x01; + let mut changed_public_key = response.clone(); + changed_public_key.public_key = + KeyExchangeKey::read_from_bytes(&[4u8; 32]).unwrap().public_key().to_bytes(); + let mut injected_next_key = response.clone(); + injected_next_key.next_key = Some(proto::transaction::NextTransactionEncryptionKey { + scheme: response.scheme, key_id: response.key_id.clone(), public_key: response.public_key.clone(), rotation_block_num: 100, - }; + }); - let tampered_commitments = [ - attestation_commitment(scheme + 1, &response.key_id, genesis, &response.public_key, None), - attestation_commitment(scheme, &tampered_key_id, genesis, &response.public_key, None), - attestation_commitment(scheme, &extended_key_id, genesis, &response.public_key[1..], None), - attestation_commitment(scheme, &response.key_id, genesis, &tampered_public_key, None), - attestation_commitment( - scheme, - &response.key_id, - tampered_genesis, - &response.public_key, - None, - ), - attestation_commitment( - scheme, - &response.key_id, - genesis, - &response.public_key, - Some(&injected_next_key), - ), - ]; - for commitment in tampered_commitments { + for tampered in [changed_scheme, changed_key_id, changed_public_key, injected_next_key] { assert!( - !signature.verify(commitment, &signing_key), + verify_transaction_encryption_key(tampered, trusted).is_err(), "attestation must not verify over tampered fields", ); } + + let tampered_genesis = Word::try_from([9u64, 9, 9, 9]).unwrap(); + assert!( + verify_transaction_encryption_key( + response, + TrustedTransactionEncryptionState::new(tampered_genesis, &trusted_keys), + ) + .is_err(), + "attestation must not verify for another network", + ); } /// A client can reconstruct the sealing key from the response fields and seal a payload that any @@ -916,3 +978,108 @@ async fn encryption_key_available_during_backup() { drop(stream); } + +// SUBMIT PATH: TRANSACTION INPUT SEALING +// ================================================================================================ + +/// A submission with no encrypted inputs is rejected before validation. +#[tokio::test] +async fn submit_rejects_missing_encrypted_inputs() { + let tv = TestValidator::new().await; + let tx = dummy_proven_tx(2); + let request = tonic::Request::new(proto::transaction::ProvenTransaction { + transaction: tx.to_bytes(), + sealed_transaction_inputs: None, + }); + + let status = validator_api::SubmitProvenTransaction::full(&tv.server, request) + .await + .unwrap_err(); + + assert_eq!(status.code(), tonic::Code::InvalidArgument); + assert!(status.message().contains("Missing sealed transaction inputs")); +} + +/// 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, 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, 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, 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, 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..829ce52bd0 100644 --- a/bin/validator/src/signers/mod.rs +++ b/bin/validator/src/signers/mod.rs @@ -1,5 +1,9 @@ mod kms; pub use kms::{KmsSigner, decrypt_key_material}; +use miden_node_proto::domain::encryption::{ + TransactionEncryptionKeyInfo, + TransactionEncryptionScheme, +}; use miden_node_utils::spawn::spawn_blocking_in_current_span; use miden_protocol::Word; use miden_protocol::crypto::dsa::ecdsa_k256_keccak::{PublicKey, Signature, SigningKey}; @@ -9,7 +13,7 @@ use miden_protocol::crypto::dsa::eddsa_25519_sha512::{ }; #[cfg(test)] use miden_protocol::crypto::ies::SealingKey; -use miden_protocol::crypto::ies::{IesScheme, SealedMessage, UnsealingKey}; +use miden_protocol::crypto::ies::{SealedMessage, UnsealingKey}; use miden_protocol::utils::serde::{Deserializable, Serializable}; // VALIDATOR SIGNER @@ -63,10 +67,6 @@ impl ValidatorSigner { // TRANSACTION INPUT DECRYPTER // ================================================================================================= -/// Domain tag prefixed to the attestation payload, separating key attestations from block header -/// signatures made with the same validator key. -pub const ATTESTATION_DOMAIN: &[u8] = b"MIDEN_TX_ENCRYPTION_KEY_ATTESTATION_V1"; - /// Decryption counterpart to [`ValidatorSigner`] for the shared transaction encryption /// (submission) key. /// @@ -93,113 +93,6 @@ pub trait TransactionInputDecrypter: Send + Sync { ) -> anyhow::Result>; } -/// Public metadata of the shared transaction encryption key, in wire format. -/// -/// These are the attested fields served by the `GetTransactionEncryptionKey` endpoint. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct TransactionEncryptionKeyInfo { - /// Wire identifier of the encryption scheme. - pub scheme: u32, - /// Opaque identifier of the current encryption key. - pub key_id: Vec, - /// Raw public key bytes of the shared encryption key. - pub public_key: Vec, - /// The next encryption key when a rotation is scheduled. Not populated yet; key rotation is not - /// implemented. - pub next_key: Option, -} - -/// Public metadata of the next transaction encryption key, announced ahead of a scheduled rotation, -/// in wire format. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct NextEncryptionKeyInfo { - /// Wire identifier of the next key's encryption scheme. - pub scheme: u32, - /// Opaque identifier of the next encryption key. - pub key_id: Vec, - /// Raw public key bytes of the next encryption key. - pub public_key: Vec, - /// Block number at which the next key replaces the current one. - pub rotation_block_num: u32, -} - -impl TransactionEncryptionKeyInfo { - /// Returns the commitment signed by a validator to attest the encryption key. - pub fn attestation_commitment(&self, genesis_commitment: Word) -> Word { - attestation_commitment( - self.scheme, - &self.key_id, - genesis_commitment, - &self.public_key, - self.next_key.as_ref(), - ) - } -} - -/// Computes the attestation commitment over explicit wire-format fields. -/// -/// This is the single definition of the attestation payload. Verifiers (and tests) recompute the -/// commitment from response fields through this function, so any change to the payload layout -/// applies to both sides. -/// -/// Computed as the Poseidon2 hash of `ATTESTATION_DOMAIN || scheme || len(key_id) || key_id || -/// genesis_commitment || len(public_key) || public_key || next_key_transcript`, binding every -/// field of the attested response to the signature. The scheme, the rotation block number, and -/// the length prefixes are encoded as 4 bytes little-endian, and the length prefixes on the -/// variable-width fields ensure no two field combinations map to the same payload. Including the -/// genesis commitment ties the attestation to one chain, so it cannot be replayed on another -/// network whose validator reuses the same signing key. -/// -/// `next_key_transcript` is empty when no rotation is scheduled, or the next key's `scheme || -/// len(key_id) || key_id || len(public_key) || public_key || rotation_block_num` otherwise. All -/// fields ahead of it are fixed-width or length-prefixed, so the transcript's presence and -/// content are unambiguous and a scheduled rotation cannot be stripped from or injected into an -/// attested response. -pub fn attestation_commitment( - scheme: u32, - key_id: &[u8], - genesis_commitment: Word, - public_key: &[u8], - next_key: Option<&NextEncryptionKeyInfo>, -) -> Word { - let genesis_commitment = genesis_commitment.to_bytes(); - let next_key_size = next_key - .map(|next| 3 * size_of::() + next.key_id.len() + next.public_key.len()) - .unwrap_or_default(); - let mut payload = Vec::with_capacity( - ATTESTATION_DOMAIN.len() - + 3 * size_of::() - + key_id.len() - + genesis_commitment.len() - + public_key.len() - + next_key_size, - ); - payload.extend_from_slice(ATTESTATION_DOMAIN); - payload.extend_from_slice(&scheme.to_le_bytes()); - extend_with_length_prefixed(&mut payload, key_id, "key id"); - payload.extend_from_slice(&genesis_commitment); - extend_with_length_prefixed(&mut payload, public_key, "public key"); - if let Some(next) = next_key { - payload.extend_from_slice(&next.scheme.to_le_bytes()); - extend_with_length_prefixed(&mut payload, &next.key_id, "next key id"); - extend_with_length_prefixed(&mut payload, &next.public_key, "next public key"); - payload.extend_from_slice(&next.rotation_block_num.to_le_bytes()); - } - miden_protocol::Hasher::hash(&payload) -} - -/// Appends a field to the attestation payload prefixed with its length as 4 bytes little-endian. -/// -/// The length prefixes on variable-width fields keep the transcript injective: no two field -/// combinations map to the same payload. -fn extend_with_length_prefixed(payload: &mut Vec, field: &[u8], name: &str) { - let len = u32::try_from(field.len()) - .unwrap_or_else(|_| panic!("{name} length must fit in u32")) - .to_le_bytes(); - payload.extend_from_slice(&len); - payload.extend_from_slice(field); -} - /// [`TransactionInputDecrypter`] backed by a locally provisioned X25519 shared secret. pub struct LocalX25519TransactionInputDecrypter { secret_key: KeyExchangeKey, @@ -207,18 +100,14 @@ pub struct LocalX25519TransactionInputDecrypter { impl LocalX25519TransactionInputDecrypter { /// The IES scheme used for transaction input encryption. - pub const SCHEME: IesScheme = IesScheme::X25519XChaCha20Poly1305; + pub const SCHEME: TransactionEncryptionScheme = + TransactionEncryptionScheme::X25519XChaCha20Poly1305; /// Constructs a decrypter from a locally provisioned shared secret. pub fn new(secret_key: KeyExchangeKey) -> Self { Self { secret_key } } - /// Returns the wire representation of [`Self::SCHEME`]. - pub fn scheme_id() -> u32 { - u32::from(u8::from(Self::SCHEME)) - } - /// Returns the public key of the shared encryption key. pub fn public_key(&self) -> EncryptionPublicKey { self.secret_key.public_key() @@ -241,7 +130,7 @@ impl LocalX25519TransactionInputDecrypter { impl TransactionInputDecrypter for LocalX25519TransactionInputDecrypter { async fn encryption_key(&self) -> anyhow::Result { Ok(TransactionEncryptionKeyInfo { - scheme: Self::scheme_id(), + scheme: Self::SCHEME, key_id: self.key_id(), public_key: self.public_key().to_bytes(), next_key: None, @@ -257,9 +146,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/compose/monitor.yml b/compose/monitor.yml index 23465b564d..6192885cd3 100644 --- a/compose/monitor.yml +++ b/compose/monitor.yml @@ -15,6 +15,8 @@ services: MIDEN_MONITOR_RPC_URL: http://sequencer:57291 MIDEN_MONITOR_PORT: "3001" MIDEN_MONITOR_NETWORK_NAME: Localhost + # Public key for validator 1's insecure default development signing key. + MIDEN_MONITOR_VALIDATOR_SIGNING_PUBLIC_KEY: 031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4317 ports: - "127.0.0.1:3001:3001" diff --git a/compose/validator.yml b/compose/validator.yml index 93eb2efb51..c3fbed644b 100644 --- a/compose/validator.yml +++ b/compose/validator.yml @@ -17,6 +17,8 @@ services: validator-1: <<: *validator environment: + MIDEN_VALIDATOR_ENCRYPTION_KEY: + MIDEN_VALIDATOR_ENCRYPTION_KEY_KMS_CIPHERTEXT: MIDEN_VALIDATOR_DATA_DIRECTORY: /data/validators/1 MIDEN_VALIDATOR_SIGNING_KEY: ${MIDEN_VALIDATOR_1_SIGNING_KEY:-0101010101010101010101010101010101010101010101010101010101010101} OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4317 @@ -25,6 +27,8 @@ services: validator-2: <<: *validator environment: + MIDEN_VALIDATOR_ENCRYPTION_KEY: + MIDEN_VALIDATOR_ENCRYPTION_KEY_KMS_CIPHERTEXT: MIDEN_VALIDATOR_DATA_DIRECTORY: /data/validators/2 MIDEN_VALIDATOR_SIGNING_KEY: ${MIDEN_VALIDATOR_2_SIGNING_KEY:-0303030303030303030303030303030303030303030303030303030303030303} OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4317 @@ -33,6 +37,8 @@ services: validator-3: <<: *validator environment: + MIDEN_VALIDATOR_ENCRYPTION_KEY: + MIDEN_VALIDATOR_ENCRYPTION_KEY_KMS_CIPHERTEXT: MIDEN_VALIDATOR_DATA_DIRECTORY: /data/validators/3 MIDEN_VALIDATOR_SIGNING_KEY: ${MIDEN_VALIDATOR_3_SIGNING_KEY:-0404040404040404040404040404040404040404040404040404040404040404} OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4317 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..1d9040de66 100644 --- a/crates/proto/src/clients/mod.rs +++ b/crates/proto/src/clients/mod.rs @@ -32,6 +32,7 @@ use std::time::Duration; use http::header::ACCEPT; use miden_node_utils::tracing::grpc::OtelInterceptor; +use miden_protocol::Word; use miden_protocol::batch::ProposedBatch; use miden_protocol::utils::serde::Serializable; use tonic::metadata::AsciiMetadataValue; @@ -164,6 +165,8 @@ type GeneratedProverClient = generated::remote_prover::api_client::ApiClient; type GeneratedNtxBuilderClient = generated::ntx_builder::api_client::ApiClient; type GeneratedSequencerClient = generated::sequencer::api_client::ApiClient; +type GeneratedProvenTransaction = generated::transaction::ProvenTransaction; +type SealedTransactionInputs = generated::transaction::SealedTransactionInputs; // gRPC CLIENTS // ================================================================================================ @@ -343,7 +346,7 @@ impl GrpcClient for SequencerClient { pub struct Builder { endpoint: Endpoint, metadata_version: Option, - metadata_genesis: Option, + metadata_genesis: Option, metadata_auth_header_value: Option, enable_otel: bool, _state: PhantomData, @@ -453,8 +456,8 @@ impl Builder { self.next_state() } - /// Include a specific genesis commitment string in request metadata. - pub fn with_metadata_genesis(mut self, genesis: String) -> Builder { + /// Include a specific genesis commitment in request metadata. + pub fn with_metadata_genesis(mut self, genesis: Word) -> Builder { self.metadata_genesis = Some(genesis); self.next_state() } @@ -515,10 +518,11 @@ impl Builder { where T: GrpcClient, { + let metadata_genesis = self.metadata_genesis.map(|genesis| genesis.to_hex()); let interceptor = Interceptor::new( self.enable_otel, self.metadata_version.as_deref(), - self.metadata_genesis.as_deref(), + metadata_genesis.as_deref(), self.metadata_auth_header_value, ); T::with_interceptor(channel, interceptor) @@ -530,21 +534,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: &[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) { - let proven_tx = generated::transaction::ProvenTransaction { + for (tx, inputs) in proposed_batch.transactions().iter().zip(sealed_transaction_inputs) { + let proven_tx = GeneratedProvenTransaction { 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..b75305aaa3 --- /dev/null +++ b/crates/proto/src/domain/encryption.rs @@ -0,0 +1,691 @@ +//! 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::ecdsa_k256_keccak::{ + PublicKey as ValidatorPublicKey, + Signature as ValidatorSignature, +}; +use miden_protocol::crypto::dsa::eddsa_25519_sha512::PublicKey as EncryptionPublicKey; +use miden_protocol::crypto::ies::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"; + +/// Domain tag prefixed to the validator-signed encryption key payload. +pub const ATTESTATION_DOMAIN: &[u8] = b"MIDEN_TX_ENCRYPTION_KEY_ATTESTATION_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; + +// ENCRYPTION KEY +// ================================================================================================ + +/// Encryption schemes supported by transaction input submission. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u32)] +pub enum TransactionEncryptionScheme { + /// X25519 key agreement with XChaCha20-Poly1305 authenticated encryption. + X25519XChaCha20Poly1305 = SCHEME_X25519_XCHACHA20_POLY1305, +} + +impl TransactionEncryptionScheme { + /// Returns the integer used for this scheme on the wire and in signed transcripts. + pub const fn as_u32(self) -> u32 { + self as u32 + } + + /// Returns the protobuf enum value for this scheme. + pub const fn as_i32(self) -> i32 { + self as i32 + } +} + +impl TryFrom for TransactionEncryptionScheme { + type Error = TransactionEncryptionKeyError; + + fn try_from(value: i32) -> Result { + match value { + 0 => Err(TransactionEncryptionKeyError::UnspecifiedScheme), + 1 => Ok(Self::X25519XChaCha20Poly1305), + other => Err(TransactionEncryptionKeyError::UnsupportedScheme(other)), + } + } +} + +/// Public metadata for a scheduled transaction encryption key. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NextEncryptionKeyInfo { + /// Encryption scheme for the scheduled key. + pub scheme: TransactionEncryptionScheme, + /// Opaque identifier of the scheduled key. + pub key_id: Vec, + /// Encoded public key. + pub public_key: Vec, + /// Block at which the scheduled key becomes current. + pub rotation_block_num: u32, +} + +/// Public metadata for the transaction encryption key served by a validator. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TransactionEncryptionKeyInfo { + /// Encryption scheme for the current key. + pub scheme: TransactionEncryptionScheme, + /// Opaque identifier of the current key. + pub key_id: Vec, + /// Encoded public key. + pub public_key: Vec, + /// Scheduled replacement key, when one exists. + pub next_key: Option, +} + +impl TransactionEncryptionKeyInfo { + /// Returns the commitment a validator signs to attest this key for one network. + pub fn attestation_commitment(&self, genesis_commitment: Word) -> Word { + attestation_commitment( + self.scheme, + &self.key_id, + genesis_commitment, + &self.public_key, + self.next_key.as_ref(), + ) + } +} + +/// Trusted chain state used to verify a served transaction encryption key. +#[derive(Debug, Clone, Copy)] +pub struct TrustedTransactionEncryptionState<'a> { + genesis_commitment: Word, + validator_signing_keys: &'a [ValidatorPublicKey], +} + +impl<'a> TrustedTransactionEncryptionState<'a> { + /// Creates trusted state from a genesis commitment and its validator signing keys. + pub const fn new( + genesis_commitment: Word, + validator_signing_keys: &'a [ValidatorPublicKey], + ) -> Self { + Self { + genesis_commitment, + validator_signing_keys, + } + } +} + +/// A transaction encryption key whose attestation matches trusted chain state. +#[derive(Debug, Clone)] +pub struct VerifiedTransactionEncryptionKey { + info: TransactionEncryptionKeyInfo, + public_key: EncryptionPublicKey, + genesis_commitment: Word, +} + +impl VerifiedTransactionEncryptionKey { + /// Returns the verified key metadata. + pub const fn info(&self) -> &TransactionEncryptionKeyInfo { + &self.info + } + + /// Returns the decoded encryption public key. + pub const fn public_key(&self) -> &EncryptionPublicKey { + &self.public_key + } + + /// Returns the network genesis commitment covered by the attestation. + pub const fn genesis_commitment(&self) -> Word { + self.genesis_commitment + } +} + +// 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 decode or verify a served transaction encryption key. +#[derive(Debug, thiserror::Error)] +pub enum TransactionEncryptionKeyError { + #[error("encryption key scheme is unspecified")] + UnspecifiedScheme, + #[error("unsupported encryption key scheme {0}")] + UnsupportedScheme(i32), + #[error("{field} is empty")] + EmptyKeyId { field: &'static str }, + #[error("{field} is {len} bytes, which exceeds the maximum of {MAX_KEY_ID_LEN}")] + KeyIdTooLong { field: &'static str, len: usize }, + #[error("invalid {field}")] + InvalidEncryptionPublicKey { + field: &'static str, + #[source] + source: miden_protocol::utils::serde::DeserializationError, + }, + #[error("trusted validator signing keys are empty")] + NoTrustedValidatorKeys, + #[error("transaction encryption key has no validator attestations")] + NoAttestations, + #[error("transaction encryption key has no attestation from a trusted validator")] + NoTrustedAttestation, + #[error("trusted validator attestation does not cover the transaction encryption key")] + InvalidAttestation, +} + +/// Failure to seal transaction inputs. +#[derive(Debug, thiserror::Error)] +pub enum TransactionInputSealError { + #[error("failed to seal the transaction inputs")] + Seal(#[source] miden_protocol::crypto::ies::IesError), +} + +// ATTESTATION +// ================================================================================================ + +/// Verifies a served transaction encryption key against trusted chain state. +pub fn verify_transaction_encryption_key( + key: proto::transaction::TransactionEncryptionKey, + trusted: TrustedTransactionEncryptionState<'_>, +) -> Result { + if trusted.validator_signing_keys.is_empty() { + return Err(TransactionEncryptionKeyError::NoTrustedValidatorKeys); + } + if key.attestations.is_empty() { + return Err(TransactionEncryptionKeyError::NoAttestations); + } + + let (info, public_key) = decode_key_info(&key)?; + let commitment = info.attestation_commitment(trusted.genesis_commitment); + let mut found_trusted_signer = false; + + for attestation in key.attestations { + let Ok(validator_public_key) = + ValidatorPublicKey::read_from_bytes(&attestation.validator_public_key) + else { + continue; + }; + + if !trusted.validator_signing_keys.contains(&validator_public_key) { + continue; + } + found_trusted_signer = true; + + let Ok(signature) = ValidatorSignature::read_from_bytes(&attestation.signature) else { + continue; + }; + if signature.verify(commitment, &validator_public_key) { + return Ok(VerifiedTransactionEncryptionKey { + info, + public_key, + genesis_commitment: trusted.genesis_commitment, + }); + } + } + + if found_trusted_signer { + Err(TransactionEncryptionKeyError::InvalidAttestation) + } else { + Err(TransactionEncryptionKeyError::NoTrustedAttestation) + } +} + +/// Decodes all key fields which are covered by the validator attestation. +fn decode_key_info( + key: &proto::transaction::TransactionEncryptionKey, +) -> Result<(TransactionEncryptionKeyInfo, EncryptionPublicKey), TransactionEncryptionKeyError> { + let scheme = TransactionEncryptionScheme::try_from(key.scheme)?; + validate_key_id(&key.key_id, "encryption key id")?; + let public_key = EncryptionPublicKey::read_from_bytes(&key.public_key).map_err(|source| { + TransactionEncryptionKeyError::InvalidEncryptionPublicKey { + field: "encryption public key", + source, + } + })?; + + let next_key = key + .next_key + .as_ref() + .map(|next| { + let scheme = TransactionEncryptionScheme::try_from(next.scheme)?; + validate_key_id(&next.key_id, "next encryption key id")?; + EncryptionPublicKey::read_from_bytes(&next.public_key).map_err(|source| { + TransactionEncryptionKeyError::InvalidEncryptionPublicKey { + field: "next encryption public key", + source, + } + })?; + + Ok(NextEncryptionKeyInfo { + scheme, + key_id: next.key_id.clone(), + public_key: next.public_key.clone(), + rotation_block_num: next.rotation_block_num, + }) + }) + .transpose()?; + + Ok(( + TransactionEncryptionKeyInfo { + scheme, + key_id: key.key_id.clone(), + public_key: key.public_key.clone(), + next_key, + }, + public_key, + )) +} + +/// Validates a key identifier before it is used in a transcript or allocation. +fn validate_key_id( + key_id: &[u8], + field: &'static str, +) -> Result<(), TransactionEncryptionKeyError> { + if key_id.is_empty() { + return Err(TransactionEncryptionKeyError::EmptyKeyId { field }); + } + if key_id.len() > MAX_KEY_ID_LEN { + return Err(TransactionEncryptionKeyError::KeyIdTooLong { field, len: key_id.len() }); + } + Ok(()) +} + +/// Computes the validator-signed commitment over transaction encryption key metadata. +fn attestation_commitment( + scheme: TransactionEncryptionScheme, + key_id: &[u8], + genesis_commitment: Word, + public_key: &[u8], + next_key: Option<&NextEncryptionKeyInfo>, +) -> Word { + let genesis_commitment = genesis_commitment.to_bytes(); + let next_key_size = next_key + .map(|next| 3 * size_of::() + next.key_id.len() + next.public_key.len()) + .unwrap_or_default(); + let mut payload = Vec::with_capacity( + ATTESTATION_DOMAIN.len() + + 3 * size_of::() + + key_id.len() + + genesis_commitment.len() + + public_key.len() + + next_key_size, + ); + payload.extend_from_slice(ATTESTATION_DOMAIN); + payload.extend_from_slice(&scheme.as_u32().to_le_bytes()); + extend_with_length_prefixed(&mut payload, key_id, "key id"); + payload.extend_from_slice(&genesis_commitment); + extend_with_length_prefixed(&mut payload, public_key, "public key"); + if let Some(next) = next_key { + payload.extend_from_slice(&next.scheme.as_u32().to_le_bytes()); + extend_with_length_prefixed(&mut payload, &next.key_id, "next key id"); + extend_with_length_prefixed(&mut payload, &next.public_key, "next public key"); + payload.extend_from_slice(&next.rotation_block_num.to_le_bytes()); + } + miden_protocol::Hasher::hash(&payload) +} + +/// Appends a length-prefixed field to the attestation transcript. +fn extend_with_length_prefixed(payload: &mut Vec, field: &[u8], name: &str) { + let len = u32::try_from(field.len()) + .unwrap_or_else(|_| panic!("{name} length must fit in u32")) + .to_le_bytes(); + payload.extend_from_slice(&len); + payload.extend_from_slice(field); +} + +// SEALER +// ================================================================================================ + +/// Seals transaction inputs against the validator set's shared encryption key. +/// +/// Built from a verified transaction encryption key 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. +#[derive(Debug, Clone)] +pub struct TransactionInputsSealer { + scheme: TransactionEncryptionScheme, + key_id: Vec, + sealing_key: SealingKey, + genesis_commitment: Word, +} + +impl TransactionInputsSealer { + /// Builds a sealer from a key whose validator attestation has already been verified. + pub fn new(key: VerifiedTransactionEncryptionKey) -> Self { + Self { + scheme: key.info.scheme, + key_id: key.info.key_id, + sealing_key: SealingKey::X25519XChaCha20Poly1305(key.public_key), + genesis_commitment: 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.as_u32(), + &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::ecdsa_k256_keccak::SigningKey; + 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]), + ) + } + + fn signing_key(seed: u8) -> SigningKey { + SigningKey::read_from_bytes(&[seed; 32]).expect("test signing key should decode") + } + + fn unsigned_encryption_key() -> proto::transaction::TransactionEncryptionKey { + proto::transaction::TransactionEncryptionKey { + scheme: TransactionEncryptionScheme::X25519XChaCha20Poly1305.as_i32(), + 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, + } + } + + fn signed_encryption_key( + signer: &SigningKey, + genesis_commitment: Word, + ) -> proto::transaction::TransactionEncryptionKey { + let mut key = unsigned_encryption_key(); + let (info, _) = decode_key_info(&key).unwrap(); + key.attestations = vec![proto::transaction::ValidatorKeyAttestation { + validator_public_key: signer.public_key().to_bytes(), + signature: signer.sign(info.attestation_commitment(genesis_commitment)).to_bytes(), + }]; + key + } + + /// A key signed by the validator committed in trusted chain state verifies. + #[test] + fn verifies_trusted_validator_attestation() { + let signer = signing_key(1); + let trusted_keys = [signer.public_key()]; + let key = signed_encryption_key(&signer, genesis()); + + let verified = verify_transaction_encryption_key( + key, + TrustedTransactionEncryptionState::new(genesis(), &trusted_keys), + ) + .unwrap(); + + assert_eq!(verified.info().key_id, TEST_KEY_ID); + assert_eq!(verified.info().scheme, TransactionEncryptionScheme::X25519XChaCha20Poly1305); + assert_eq!(verified.genesis_commitment(), genesis()); + } + + /// An untrusted RPC cannot omit or rely on a malformed validator attestation. + #[test] + fn rejects_missing_and_malformed_attestations() { + let signer = signing_key(1); + let trusted_keys = [signer.public_key()]; + let trusted = TrustedTransactionEncryptionState::new(genesis(), &trusted_keys); + + assert_matches!( + verify_transaction_encryption_key(unsigned_encryption_key(), trusted), + Err(TransactionEncryptionKeyError::NoAttestations) + ); + + let mut malformed_key = signed_encryption_key(&signer, genesis()); + malformed_key.attestations[0].validator_public_key.clear(); + assert_matches!( + verify_transaction_encryption_key(malformed_key, trusted), + Err(TransactionEncryptionKeyError::NoTrustedAttestation) + ); + + let mut malformed_signature = signed_encryption_key(&signer, genesis()); + malformed_signature.attestations[0].signature.clear(); + assert_matches!( + verify_transaction_encryption_key(malformed_signature, trusted), + Err(TransactionEncryptionKeyError::InvalidAttestation) + ); + } + + /// A malformed attestation does not hide a later valid attestation. + #[test] + fn skips_malformed_attestations() { + let signer = signing_key(1); + let trusted_keys = [signer.public_key()]; + let mut key = signed_encryption_key(&signer, genesis()); + key.attestations.insert( + 0, + proto::transaction::ValidatorKeyAttestation { + validator_public_key: Vec::new(), + signature: Vec::new(), + }, + ); + + verify_transaction_encryption_key( + key, + TrustedTransactionEncryptionState::new(genesis(), &trusted_keys), + ) + .unwrap(); + } + + /// A valid signature does not help when its signer is absent from trusted chain state. + #[test] + fn rejects_untrusted_validator_attestation() { + let trusted_signer = signing_key(1); + let untrusted_signer = signing_key(2); + let trusted_keys = [trusted_signer.public_key()]; + + assert_matches!( + verify_transaction_encryption_key( + signed_encryption_key(&untrusted_signer, genesis()), + TrustedTransactionEncryptionState::new(genesis(), &trusted_keys), + ), + Err(TransactionEncryptionKeyError::NoTrustedAttestation) + ); + } + + /// Every served key field and the network identity are covered by the signature. + #[test] + fn rejects_changed_attested_fields() { + let signer = signing_key(1); + let trusted_keys = [signer.public_key()]; + let trusted = TrustedTransactionEncryptionState::new(genesis(), &trusted_keys); + let key = signed_encryption_key(&signer, genesis()); + + let mut changed_scheme = key.clone(); + changed_scheme.scheme = 0; + let mut changed_key_id = key.clone(); + changed_key_id.key_id[0] ^= 1; + let mut changed_public_key = key.clone(); + changed_public_key.public_key = + KeyExchangeKey::read_from_bytes(&[8u8; 32]).unwrap().public_key().to_bytes(); + let mut injected_next_key = key.clone(); + injected_next_key.next_key = Some(proto::transaction::NextTransactionEncryptionKey { + scheme: key.scheme, + key_id: vec![1, 2, 3, 4], + public_key: KeyExchangeKey::read_from_bytes(&[9u8; 32]) + .unwrap() + .public_key() + .to_bytes(), + rotation_block_num: 100, + }); + + for changed in [changed_scheme, changed_key_id, changed_public_key, injected_next_key] { + assert!(verify_transaction_encryption_key(changed, trusted).is_err()); + } + + assert_matches!( + verify_transaction_encryption_key( + key, + TrustedTransactionEncryptionState::new(Word::from([9u32, 9, 9, 9]), &trusted_keys), + ), + Err(TransactionEncryptionKeyError::InvalidAttestation) + ); + } + + /// Key metadata is bounded and decoded before it can become domain state. + #[test] + fn rejects_invalid_key_metadata() { + let signer = signing_key(1); + let trusted_keys = [signer.public_key()]; + let trusted = TrustedTransactionEncryptionState::new(genesis(), &trusted_keys); + + let mut empty_key_id = signed_encryption_key(&signer, genesis()); + empty_key_id.key_id.clear(); + assert_matches!( + verify_transaction_encryption_key(empty_key_id, trusted), + Err(TransactionEncryptionKeyError::EmptyKeyId { .. }) + ); + + let mut oversized_key_id = signed_encryption_key(&signer, genesis()); + oversized_key_id.key_id = vec![0; MAX_KEY_ID_LEN + 1]; + assert_matches!( + verify_transaction_encryption_key(oversized_key_id, trusted), + Err(TransactionEncryptionKeyError::KeyIdTooLong { .. }) + ); + + let mut invalid_public_key = signed_encryption_key(&signer, genesis()); + invalid_public_key.public_key.clear(); + assert_matches!( + verify_transaction_encryption_key(invalid_public_key, trusted), + Err(TransactionEncryptionKeyError::InvalidEncryptionPublicKey { .. }) + ); + } + + /// 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); + } +} 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 2b9a8aad47..6888015904 100644 --- a/crates/rpc/src/server/api.rs +++ b/crates/rpc/src/server/api.rs @@ -62,11 +62,11 @@ pub(crate) async fn submit_tx_to_validators( pub(crate) async fn submit_batch_to_validators( validators: &[miden_node_proto::clients::ValidatorClient], proposed_batch: &miden_protocol::batch::ProposedBatch, - transaction_inputs: &[Vec], + sealed_transaction_inputs: &[proto::transaction::SealedTransactionInputs], ) -> tonic::Result<()> { futures::future::try_join_all(validators.iter().map(|validator| { let mut validator = validator.clone(); - async move { validator.submit_batch(proposed_batch, transaction_inputs).await } + async move { validator.submit_batch(proposed_batch, sealed_transaction_inputs).await } })) .await?; Ok(()) diff --git a/crates/rpc/src/server/api/submit_proven_tx.rs b/crates/rpc/src/server/api/submit_proven_tx.rs index db3f944634..610641502d 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 faba298f38..04f48835ab 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,11 @@ 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() }, + "Received transaction batch", + ); 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 +80,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() ))); } @@ -109,7 +113,7 @@ impl proto::server::rpc_api::SubmitProvenTxBatch for RpcService { submit_batch_to_validators( validators.as_slice(), &proposed_batch, - &request.transaction_inputs, + &request.sealed_transaction_inputs, ) .await?; block_producer @@ -125,7 +129,7 @@ impl proto::server::rpc_api::SubmitProvenTxBatch for RpcService { pre_auth.validators().as_slice(), pre_auth.sequencer().clone(), proposed_batch, - &request.transaction_inputs, + &request.sealed_transaction_inputs, ) .await }, @@ -157,9 +161,9 @@ impl RpcService { validators: &[ValidatorClient], mut sequencer: SequencerClient, proposed_batch: ProposedBatch, - transaction_inputs: &[Vec], + sealed_transaction_inputs: &[proto::transaction::SealedTransactionInputs], ) -> tonic::Result { - submit_batch_to_validators(validators, &proposed_batch, transaction_inputs).await?; + submit_batch_to_validators(validators, &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 d09df23345..fa51565966 100644 --- a/crates/rpc/src/tests.rs +++ b/crates/rpc/src/tests.rs @@ -417,7 +417,7 @@ async fn rpc_server_rejects_proven_transactions_with_invalid_commitment() { .without_tls() .with_timeout(Duration::from_secs(5)) .without_metadata_version() - .with_metadata_genesis(genesis.to_hex()) + .with_metadata_genesis(genesis) .without_otel_context_injection() .connect_lazy::(); @@ -437,7 +437,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; @@ -465,7 +465,7 @@ async fn rpc_server_rejects_proven_transactions_with_invalid_reference_block() { .without_tls() .with_timeout(Duration::from_secs(5)) .without_metadata_version() - .with_metadata_genesis(genesis.to_hex()) + .with_metadata_genesis(genesis) .without_otel_context_injection() .connect_lazy::(); @@ -476,7 +476,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; @@ -515,7 +515,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( @@ -883,6 +883,16 @@ async fn full_node_with_validator_forwards_get_transaction_encryption_key() { assert_eq!(response, expected); assert_eq!(validator_call_count.load(Ordering::SeqCst), 1); + + full_node + .get_transaction_encryption_key(Request::new(())) + .await + .expect("each encryption key request should reach the validator"); + assert_eq!( + validator_call_count.load(Ordering::SeqCst), + 2, + "the public RPC must not cache transaction encryption keys", + ); } #[tokio::test] @@ -1045,7 +1055,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/crates/store/src/state/loader.rs b/crates/store/src/state/loader.rs index 52ebea6b20..1776179885 100644 --- a/crates/store/src/state/loader.rs +++ b/crates/store/src/state/loader.rs @@ -35,11 +35,13 @@ use miden_protocol::{Felt, Word}; #[cfg(feature = "rocksdb")] use tracing::info; +use crate::COMPONENT; +#[cfg(feature = "rocksdb")] +use crate::LOG_TARGET; use crate::account_state_forest::AccountStateForest; use crate::db::Db; use crate::db::models::queries::BlockHeaderCommitment; use crate::errors::{DatabaseError, StateInitializationError}; -use crate::{COMPONENT, LOG_TARGET}; // CONSTANTS // ================================================================================================ @@ -290,7 +292,6 @@ impl TreeStorageLoader for RocksDbStorage { ) -> Result>, StateInitializationError> { // If RocksDB storage has data, load from it directly - use crate::LOG_TARGET; let has_data = self .has_leaves() .map_err(|e| StateInitializationError::AccountTreeIoError(e.to_string()))?; 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 fb00e1c6a6..45dc1289dc 100644 --- a/docs/external/src/network-operator/bootstrap-and-genesis.md +++ b/docs/external/src/network-operator/bootstrap-and-genesis.md @@ -149,4 +149,9 @@ miden-ntx-builder bootstrap \ The key each validator operator starts their validator with must match the public key committed for them in the genesis configuration's `validators` list. +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/monitoring.md b/docs/external/src/network-operator/monitoring.md index 6e4c2f3970..6b39a95bce 100644 --- a/docs/external/src/network-operator/monitoring.md +++ b/docs/external/src/network-operator/monitoring.md @@ -14,6 +14,10 @@ validator status, prover status, and related infrastructure. configuration, it can check RPC freshness, validator health, remote prover status, faucet availability, explorer availability, note transport, and end-to-end network transaction flows. +End-to-end transaction checks require the validator's signing public key. Set +`MIDEN_MONITOR_VALIDATOR_SIGNING_PUBLIC_KEY` to its hex encoding. The monitor uses this key to verify the validator's +transaction encryption key before it submits private inputs. + Use the binary help output for the current configuration surface: ```bash diff --git a/docs/external/src/network-operator/ntx-builder.md b/docs/external/src/network-operator/ntx-builder.md index 80bfa3182d..5c4949a873 100644 --- a/docs/external/src/network-operator/ntx-builder.md +++ b/docs/external/src/network-operator/ntx-builder.md @@ -23,6 +23,9 @@ miden-ntx-builder start \ The configured `--rpc.url` should point at a node that can reach the sequencer, either directly or through a full-node upstream chain. +The NTX builder verifies transaction encryption key attestations against the validator signing keys in its genesis data. +Existing data directories must be bootstrapped again after this storage migration so those keys are available. + The `--rpc.auth-header-value` value is sent as the fixed `x-miden-network-tx-auth` metadata header when the NTX builder submits network transactions. It must match the sequencer's `--rpc.network-tx-auth-header-value`; otherwise, those network transactions are rejected. 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/proto/proto/types/transaction.proto b/proto/proto/types/transaction.proto index b4e6d5c782..c95d2c41a6 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. @@ -87,7 +103,7 @@ message ValidatorKeyAttestation { // `next_key_transcript` is empty when no rotation is scheduled, or the next key's `scheme || // len(key_id) || key_id || len(public_key) || public_key || rotation_block_num` otherwise, // binding any scheduled rotation to the signature. The canonical construction is - // `miden_validator::attestation_commitment`. + // `miden_node_proto::domain::encryption`. bytes signature = 2; } diff --git a/scripts/bench-local.sh b/scripts/bench-local.sh index 46c5d9055f..03dfdd4792 100755 --- a/scripts/bench-local.sh +++ b/scripts/bench-local.sh @@ -35,6 +35,8 @@ USE_REMOTE_PROVER="${USE_REMOTE_PROVER:-0}" CONCURRENCY="${CONCURRENCY:-8}" WAIT_BLOCKS="${WAIT_BLOCKS:-30}" RUN_DIR="${RUN_DIR:-./bench-local-run}" +# Public key for the validator's insecure default development signing key. +VALIDATOR_SIGNING_PUBLIC_KEY="${VALIDATOR_SIGNING_PUBLIC_KEY:-031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f}" # --- ports -------------------------------------------------------------------- VALIDATOR_PORT=50101 @@ -196,9 +198,10 @@ miden-benchmark create-proofs \ say "running run-benchmark" miden-benchmark run-benchmark \ - --rpc-url "http://127.0.0.1:$RPC_PORT" \ - --concurrency "$CONCURRENCY" \ - --wait-blocks "$WAIT_BLOCKS" \ + --rpc-url "http://127.0.0.1:$RPC_PORT" \ + --validator-signing-public-key "$VALIDATOR_SIGNING_PUBLIC_KEY" \ + --concurrency "$CONCURRENCY" \ + --wait-blocks "$WAIT_BLOCKS" \ 2>&1 | tee "$LOGS/run-benchmark.log" say "done. logs in $LOGS/"