diff --git a/bin/benchmark/src/main.rs b/bin/benchmark/src/main.rs index 1fba335bcd..c41e3e3246 100644 --- a/bin/benchmark/src/main.rs +++ b/bin/benchmark/src/main.rs @@ -14,7 +14,7 @@ use miden_node_proto::clients::{Builder, RpcClient}; use miden_node_proto::domain::encryption::{ TransactionInputsSealer, TrustedTransactionEncryptionState, - verify_transaction_encryption_key, + verify_transaction_encryption_key_schedule, }; use miden_node_proto::generated::rpc::BlockHeaderByNumberRequest; use miden_protocol::Word; @@ -213,20 +213,44 @@ pub(crate) async fn create_genesis_aware_rpc_client_pool( for _ in 0..size { pool.push(build_rpc_client(rpc_url, timeout, Some(genesis)).await?); } - let key = pool[0] + let chain_tip = latest_block_num(&pool[0]).await?; + let schedule = pool[0] .clone() .get_transaction_encryption_key(()) .await - .context("Failed to fetch the transaction encryption key")? + .context("Failed to fetch the transaction encryption key schedule")? .into_inner(); let trusted_keys = [trusted_validator_signing_key]; - let verified = verify_transaction_encryption_key( - key, - TrustedTransactionEncryptionState::new(genesis, &trusted_keys), + // The chain tip comes from the same node that serves the schedule, so the epoch check detects a + // replayed schedule only as far as that node is honest about its own tip. The attestation is + // still checked against the validator signing key trusted from the chain. + let verified = verify_transaction_encryption_key_schedule( + &schedule, + TrustedTransactionEncryptionState::new(genesis, chain_tip, &trusted_keys), ) - .context("Untrusted transaction encryption key")?; + .context("Untrusted transaction encryption key schedule")?; - Ok((pool, TransactionInputsSealer::new(verified))) + Ok((pool, TransactionInputsSealer::new(verified.into_current_key()))) +} + +/// Fetches the node's current chain tip. +async fn latest_block_num(rpc: &RpcClient) -> Result { + let response = rpc + .clone() + .get_block_header_by_number(BlockHeaderByNumberRequest { + block_num: None, + include_mmr_proof: None, + }) + .await + .context("Failed to fetch the chain tip block header")? + .into_inner(); + let header: BlockHeader = response + .block_header + .context("Block header response carried no header")? + .try_into() + .context("Failed to convert the chain tip block header")?; + + Ok(header.block_num()) } pub(crate) fn get_genesis_header_request() -> BlockHeaderByNumberRequest { diff --git a/bin/network-monitor/src/deploy/mod.rs b/bin/network-monitor/src/deploy/mod.rs index 7c477f1054..90245bb4e0 100644 --- a/bin/network-monitor/src/deploy/mod.rs +++ b/bin/network-monitor/src/deploy/mod.rs @@ -13,7 +13,7 @@ use miden_node_proto::clients::{Builder, RpcClient}; use miden_node_proto::domain::encryption::{ TransactionInputsSealer, TrustedTransactionEncryptionState, - verify_transaction_encryption_key, + verify_transaction_encryption_key_schedule, }; use miden_node_proto::generated::rpc::BlockHeaderByNumberRequest; use miden_node_proto::generated::transaction::ProvenTransaction as ProtoProvenTransaction; @@ -98,28 +98,34 @@ impl TransactionSubmissionClient { self.rpc_client.clone() } - /// Returns the cached verified sealer, fetching and checking the attested key on first use. + /// Returns the cached verified sealer, fetching and checking the attested schedule on first use. + /// + /// The chain tip bounding the schedule's attestation epoch comes from the same node, so it + /// detects a replayed schedule only as far as that node is honest about its own tip. The + /// attestation is still checked against the validator signing keys committed at genesis. async fn sealer(&self) -> Result { if let Some(sealer) = self.sealer.lock().await.clone() { return Ok(sealer); } - let key = self + let chain_tip = self.latest_block_num().await?; + let schedule = self .rpc_client .clone() .get_transaction_encryption_key(()) .await - .context("Failed to fetch the transaction encryption key")? + .context("Failed to fetch the transaction encryption key schedule")? .into_inner(); - let verified = verify_transaction_encryption_key( - key, + let verified = verify_transaction_encryption_key_schedule( + &schedule, TrustedTransactionEncryptionState::new( self.genesis_commitment, + chain_tip, &self.trusted_validator_signing_keys, ), ) - .context("Untrusted transaction encryption key")?; - let sealer = TransactionInputsSealer::new(verified); + .context("Untrusted transaction encryption key schedule")?; + let sealer = TransactionInputsSealer::new(verified.into_current_key()); let mut cached = self.sealer.lock().await; if let Some(sealer) = cached.clone() { @@ -129,6 +135,27 @@ impl TransactionSubmissionClient { Ok(sealer) } + /// Fetches the node's current chain tip. + async fn latest_block_num(&self) -> Result { + let response = self + .rpc_client + .clone() + .get_block_header_by_number(BlockHeaderByNumberRequest { + block_num: None, + include_mmr_proof: None, + }) + .await + .context("Failed to fetch the chain tip block header")? + .into_inner(); + let header: BlockHeader = response + .block_header + .context("Block header response carried no header")? + .try_into() + .context("Failed to convert the chain tip block header")?; + + Ok(header.block_num()) + } + /// Seals and submits one proven transaction, retrying once with a fresh key when needed. pub async fn submit( &self, diff --git a/bin/ntx-builder/src/clients/rpc.rs b/bin/ntx-builder/src/clients/rpc.rs index 312d88cc56..1c57662b62 100644 --- a/bin/ntx-builder/src/clients/rpc.rs +++ b/bin/ntx-builder/src/clients/rpc.rs @@ -15,7 +15,7 @@ use miden_node_proto::domain::account::{ use miden_node_proto::domain::encryption::{ TransactionInputsSealer, TrustedTransactionEncryptionState, - verify_transaction_encryption_key, + verify_transaction_encryption_key_schedule, }; use miden_node_proto::errors::ConversionError; use miden_node_proto::generated::rpc::account_request::account_detail_request::{StorageMapDetailRequest, StorageMapDetailRequests, StorageRequest, storage_map_detail_request}; @@ -146,26 +146,33 @@ impl RpcClient { }) } - /// Returns a sealer for transaction inputs, fetching the encryption key if the cache is empty. + /// Returns a sealer for transaction inputs, fetching the key schedule if the cache is empty. + /// + /// The chain tip used to bound the schedule's attestation epoch comes from the same node, so it + /// detects a schedule replayed from an earlier epoch only as far as that node is honest about + /// its own tip. The attestation itself is still checked against the validator signing keys + /// committed at genesis, which is what makes the served key trustworthy at all. 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, + let chain_tip = self.latest_block_num().await?; + let schedule = self.inner.clone().get_transaction_encryption_key(()).await?.into_inner(); + let verified = verify_transaction_encryption_key_schedule( + &schedule, TrustedTransactionEncryptionState::new( self.genesis_commitment, + chain_tip, &self.trusted_validator_signing_keys, ), ) .map_err(|err| { Status::failed_precondition( - err.as_report_context("Untrusted transaction encryption key"), + err.as_report_context("Untrusted transaction encryption key schedule"), ) })?; - let sealer = TransactionInputsSealer::new(verified); + let sealer = TransactionInputsSealer::new(verified.into_current_key()); let mut cached = self.sealer.write().await; if let Some(sealer) = cached.clone() { @@ -175,6 +182,27 @@ impl RpcClient { Ok(sealer) } + /// Fetches the node's current chain tip. + async fn latest_block_num(&self) -> Result { + let response = self + .inner + .clone() + .get_block_header_by_number(proto::rpc::BlockHeaderByNumberRequest { + block_num: None, + include_mmr_proof: None, + }) + .await? + .into_inner(); + let header = response + .block_header + .ok_or_else(|| Status::internal("Block header response carried no header"))?; + let header: miden_protocol::block::BlockHeader = header + .try_into() + .map_err(|err: ConversionError| Status::internal(err.as_report()))?; + + Ok(header.block_num()) + } + /// Opens a committed-block subscription starting at `block_from`, retrying indefinitely with /// the client's configured exponential backoff while the initial connection attempt fails. /// diff --git a/bin/validator/src/commands/mod.rs b/bin/validator/src/commands/mod.rs index 40beb97c9b..096fd66a9d 100644 --- a/bin/validator/src/commands/mod.rs +++ b/bin/validator/src/commands/mod.rs @@ -15,6 +15,7 @@ use miden_node_utils::clap::GrpcOptionsInternal; use miden_node_utils::genesis::INSECURE_VALIDATOR_SIGNING_KEY_HEX; use miden_node_utils::logging::OpenTelemetry; use miden_node_utils::shutdown::CancellationToken; +use miden_protocol::block::BlockNumber; use miden_protocol::crypto::dsa::ecdsa_k256_keccak::SigningKey; use miden_protocol::crypto::dsa::eddsa_25519_sha512::KeyExchangeKey; use miden_protocol::utils::serde::{Deserializable, Serializable}; @@ -36,6 +37,17 @@ const ENV_SIGNING_KEY: &str = "MIDEN_VALIDATOR_SIGNING_KEY"; const ENV_SIGNING_KEY_KMS_ID: &str = "MIDEN_VALIDATOR_SIGNING_KEY_KMS_ID"; const ENV_ENCRYPTION_KEY: &str = "MIDEN_VALIDATOR_ENCRYPTION_KEY"; const ENV_ENCRYPTION_KEY_KMS_CIPHERTEXT: &str = "MIDEN_VALIDATOR_ENCRYPTION_KEY_KMS_CIPHERTEXT"; +const ENV_ENCRYPTION_KEY_ACTIVATION_BLOCK: &str = "MIDEN_VALIDATOR_ENCRYPTION_KEY_ACTIVATION_BLOCK"; +const ENV_PREVIOUS_ENCRYPTION_KEY: &str = "MIDEN_VALIDATOR_PREVIOUS_ENCRYPTION_KEY"; +const ENV_PREVIOUS_ENCRYPTION_KEY_KMS_CIPHERTEXT: &str = + "MIDEN_VALIDATOR_PREVIOUS_ENCRYPTION_KEY_KMS_CIPHERTEXT"; +const ENV_PREVIOUS_ENCRYPTION_KEY_ACTIVATION_BLOCK: &str = + "MIDEN_VALIDATOR_PREVIOUS_ENCRYPTION_KEY_ACTIVATION_BLOCK"; +const ENV_NEXT_ENCRYPTION_KEY: &str = "MIDEN_VALIDATOR_NEXT_ENCRYPTION_KEY"; +const ENV_NEXT_ENCRYPTION_KEY_KMS_CIPHERTEXT: &str = + "MIDEN_VALIDATOR_NEXT_ENCRYPTION_KEY_KMS_CIPHERTEXT"; +const ENV_NEXT_ENCRYPTION_KEY_ACTIVATION_BLOCK: &str = + "MIDEN_VALIDATOR_NEXT_ENCRYPTION_KEY_ACTIVATION_BLOCK"; const ENV_GENESIS_CONFIG: &str = "MIDEN_VALIDATOR_GENESIS_CONFIG"; const ENV_SQLITE_CONNECTION_POOL_SIZE: &str = "MIDEN_VALIDATOR_SQLITE_CONNECTION_POOL_SIZE"; const ENV_STORAGE_KEY_EPOCH: &str = "MIDEN_VALIDATOR_STORAGE_KEY_EPOCH"; @@ -46,6 +58,7 @@ const ENV_STORAGE_KEY_SETUP_CONTEXT: &str = "MIDEN_VALIDATOR_STORAGE_KEY_SETUP_C /// A predefined, insecure shared transaction encryption key for development purposes. pub(crate) const INSECURE_ENCRYPTION_KEY_HEX: &str = "0202020202020202020202020202020202020202020202020202020202020202"; +const INSECURE_ENCRYPTION_KEY_BYTES: [u8; 32] = [2; 32]; // VALIDATOR COMMAND // ================================================================================================ @@ -215,38 +228,9 @@ pub enum ValidatorCommand { )] signing_key_kms_id: Option, - /// Hex-encoded shared secret of the transaction encryption key. - /// - /// Unlike the per-validator signing key, this value must be identical across every - /// validator in the set. - /// - /// If not provided, a predefined insecure key is used. - /// - /// Cannot be used with `encryption-key.kms-ciphertext`. - #[arg( - long = "encryption-key.hex", - env = ENV_ENCRYPTION_KEY, - value_name = "VALIDATOR_ENCRYPTION_KEY", - default_value = INSECURE_ENCRYPTION_KEY_HEX, - group = "encryption_key_source" - )] - encryption_key: String, - - /// Base64-encoded KMS ciphertext of the shared transaction encryption key, as returned - /// by `kms:Encrypt`. - /// - /// The wrapped key material is recovered at startup with `kms:Decrypt`. The ciphertext - /// must have been produced by `kms:Encrypt` under a symmetric KMS key, whose ID is - /// embedded in the ciphertext blob. - /// - /// Cannot be used with `encryption-key.hex`. - #[arg( - long = "encryption-key.kms-ciphertext", - env = ENV_ENCRYPTION_KEY_KMS_CIPHERTEXT, - value_name = "VALIDATOR_ENCRYPTION_KEY_KMS_CIPHERTEXT", - group = "encryption_key_source" - )] - encryption_key_kms_ciphertext: Option, + /// Manual transaction encryption key schedule. + #[command(flatten)] + encryption_keys: Box, /// Canonical Golden storage key material provisioned after setup. #[command(flatten)] @@ -254,6 +238,185 @@ pub enum ValidatorCommand { }, } +#[derive(clap::Args)] +pub(crate) struct ValidatorEncryptionKeys { + /// Hex-encoded shared current transaction encryption secret key. + /// + /// Unlike the per-validator signing key, this value must be identical across every validator + /// in the set. If not provided, a predefined insecure key is used. + #[arg( + long = "encryption-key.hex", + env = ENV_ENCRYPTION_KEY, + value_name = "VALIDATOR_ENCRYPTION_KEY", + default_value = INSECURE_ENCRYPTION_KEY_HEX, + group = "encryption_key_source" + )] + current_key: String, + + /// Base64-encoded KMS ciphertext of the current transaction encryption secret key. + #[arg( + long = "encryption-key.kms-ciphertext", + env = ENV_ENCRYPTION_KEY_KMS_CIPHERTEXT, + value_name = "VALIDATOR_ENCRYPTION_KEY_KMS_CIPHERTEXT", + group = "encryption_key_source" + )] + current_key_kms_ciphertext: Option, + + /// Epoch-boundary block at which the current key became active. + #[arg( + long = "encryption-key.activation-block", + env = ENV_ENCRYPTION_KEY_ACTIVATION_BLOCK, + value_name = "BLOCK_NUM", + default_value_t = 0 + )] + current_key_activation_block: u32, + + /// Hex-encoded previous transaction encryption secret key retained for grace decryption. + #[arg( + long = "encryption-key.previous.hex", + env = ENV_PREVIOUS_ENCRYPTION_KEY, + value_name = "PREVIOUS_VALIDATOR_ENCRYPTION_KEY", + group = "previous_encryption_key_source", + requires = "previous_key_activation_block" + )] + previous_key: Option, + + /// Base64-encoded KMS ciphertext of the previous transaction encryption secret key. + #[arg( + long = "encryption-key.previous.kms-ciphertext", + env = ENV_PREVIOUS_ENCRYPTION_KEY_KMS_CIPHERTEXT, + value_name = "PREVIOUS_VALIDATOR_ENCRYPTION_KEY_KMS_CIPHERTEXT", + group = "previous_encryption_key_source", + requires = "previous_key_activation_block" + )] + previous_key_kms_ciphertext: Option, + + /// Epoch-boundary block at which the previous key became active. + #[arg( + long = "encryption-key.previous.activation-block", + env = ENV_PREVIOUS_ENCRYPTION_KEY_ACTIVATION_BLOCK, + value_name = "BLOCK_NUM", + requires = "previous_encryption_key_source" + )] + previous_key_activation_block: Option, + + /// Hex-encoded next transaction encryption secret key. + #[arg( + long = "encryption-key.next.hex", + env = ENV_NEXT_ENCRYPTION_KEY, + value_name = "NEXT_VALIDATOR_ENCRYPTION_KEY", + group = "next_encryption_key_source", + requires = "next_key_activation_block" + )] + next_key: Option, + + /// Base64-encoded KMS ciphertext of the next transaction encryption secret key. + #[arg( + long = "encryption-key.next.kms-ciphertext", + env = ENV_NEXT_ENCRYPTION_KEY_KMS_CIPHERTEXT, + value_name = "NEXT_VALIDATOR_ENCRYPTION_KEY_KMS_CIPHERTEXT", + group = "next_encryption_key_source", + requires = "next_key_activation_block" + )] + next_key_kms_ciphertext: Option, + + /// Epoch-boundary block at which the next key will become active. + #[arg( + long = "encryption-key.next.activation-block", + env = ENV_NEXT_ENCRYPTION_KEY_ACTIVATION_BLOCK, + value_name = "BLOCK_NUM", + requires = "next_encryption_key_source" + )] + next_key_activation_block: Option, +} + +impl ValidatorEncryptionKeys { + async fn into_decrypter(self) -> anyhow::Result { + let current = + load_encryption_key(Some(self.current_key), self.current_key_kms_ciphertext, "current") + .await? + .expect("the current encryption key always has a default"); + let previous = + load_encryption_key(self.previous_key, self.previous_key_kms_ciphertext, "previous") + .await?; + let next = load_encryption_key(self.next_key, self.next_key_kms_ciphertext, "next").await?; + + let previous = + pair_key_with_activation(previous, self.previous_key_activation_block, "previous")?; + let next = pair_key_with_activation(next, self.next_key_activation_block, "next")?; + + LocalX25519TransactionInputDecrypter::from_schedule( + previous, + (current, BlockNumber::from(self.current_key_activation_block)), + next, + ) + } +} + +async fn load_encryption_key( + hex_key: Option, + kms_ciphertext: Option, + role: &str, +) -> anyhow::Result> { + let loaded_from_hex = kms_ciphertext.is_none() && hex_key.is_some(); + let key_bytes = if let Some(ciphertext) = kms_ciphertext { + let ciphertext = + base64::engine::general_purpose::STANDARD.decode(ciphertext).with_context(|| { + format!("failed to decode the {role} encryption key KMS ciphertext") + })?; + Some( + miden_validator::decrypt_key_material(ciphertext) + .await + .with_context(|| format!("failed to decrypt the {role} encryption key with KMS"))?, + ) + } else { + hex_key + .map(|key| { + hex::decode(key) + .with_context(|| format!("failed to decode the {role} encryption key hex")) + }) + .transpose()? + }; + + if key_bytes.as_deref() == Some(INSECURE_ENCRYPTION_KEY_BYTES.as_slice()) { + tracing::warn!( + target: LOG_TARGET, + role, + "Using the predefined, insecure transaction encryption key" + ); + } else if loaded_from_hex { + tracing::warn!( + target: LOG_TARGET, + role, + "Using a plaintext transaction encryption key; use KMS ciphertext in production" + ); + } + + key_bytes + .map(|key| { + KeyExchangeKey::read_from_bytes(&key) + .with_context(|| format!("failed to parse the {role} transaction encryption key")) + }) + .transpose() +} + +fn pair_key_with_activation( + key: Option, + activation_block: Option, + role: &str, +) -> anyhow::Result> { + match (key, activation_block) { + (Some(key), Some(activation_block)) => Ok(Some((key, BlockNumber::from(activation_block)))), + (None, None) => Ok(None), + (Some(_), None) => { + anyhow::bail!("{role} encryption key requires an activation block") + }, + (None, Some(_)) => { + anyhow::bail!("{role} encryption key activation requires a key") + }, + } +} + impl ValidatorCommand { pub async fn handle(self, shutdown: CancellationToken) -> anyhow::Result<()> { match self { @@ -302,8 +465,7 @@ impl ValidatorCommand { data_directory, signing_key_kms_id, sqlite_connection_pool_size, - encryption_key, - encryption_key_kms_ciphertext, + encryption_keys, storage_key, .. } => { @@ -326,8 +488,8 @@ impl ValidatorCommand { "Starting validator", ); - let decrypter = - resolve_decrypter(encryption_key, encryption_key_kms_ciphertext).await?; + let decrypter: Arc = + Arc::new((*encryption_keys).into_decrypter().await?); let signer = ValidatorSigningKey { signing_key, signing_key_kms_id }.into_signer().await?; @@ -359,39 +521,6 @@ impl ValidatorCommand { } } -/// Builds the transaction input decrypter from the configured shared encryption key: either the -/// KMS-wrapped ciphertext, or the hex-encoded key material. -async fn resolve_decrypter( - encryption_key: String, - encryption_key_kms_ciphertext: Option, -) -> anyhow::Result> { - let encryption_key_bytes = if let Some(ciphertext) = encryption_key_kms_ciphertext { - let ciphertext = base64::engine::general_purpose::STANDARD - .decode(ciphertext) - .context("failed to decode the encryption key KMS ciphertext base64")?; - miden_validator::decrypt_key_material(ciphertext) - .await - .context("failed to decrypt the encryption key with KMS")? - } else { - // Unlike the signing key, whose insecure default is caught at startup against the chain's - // committed validator key, nothing cross-checks the encryption key. Warn loudly so the - // default never runs in production unnoticed. - if encryption_key == INSECURE_ENCRYPTION_KEY_HEX { - tracing::warn!( - target: LOG_TARGET, - "Using the predefined, insecure transaction encryption key, configure \ - --encryption-key.hex or --encryption-key.kms-ciphertext for production \ - deployments" - ); - } - - hex::decode(encryption_key).context("failed to decode the encryption key hex")? - }; - let encryption_key = KeyExchangeKey::read_from_bytes(&encryption_key_bytes) - .context("failed to construct the encryption key")?; - Ok(Arc::new(LocalX25519TransactionInputDecrypter::new(encryption_key))) -} - /// Canonical Golden files needed to restore one validator storage key share. #[derive(clap::Args)] pub struct ValidatorStorageKey { @@ -531,6 +660,10 @@ mod tests { type TestStorageGroup = Secp256k1GoldenGroup; + const KEY_A: &str = "0303030303030303030303030303030303030303030303030303030303030303"; + const KEY_B: &str = "0404040404040404040404040404040404040404040404040404040404040404"; + const KEY_C: &str = "0505050505050505050505050505050505050505050505050505050505050505"; + const BASE_START_ARGS: [&str; 6] = [ "miden-validator", "start", @@ -672,16 +805,14 @@ mod tests { #[test] fn encryption_key_defaults_to_insecure_hex() { let command = parse_start(&[]).expect("start without encryption options must parse"); - let ValidatorCommand::Start { - encryption_key, - encryption_key_kms_ciphertext, - .. - } = command - else { + let ValidatorCommand::Start { encryption_keys, .. } = command else { panic!("expected the start command"); }; - assert_eq!(encryption_key, INSECURE_ENCRYPTION_KEY_HEX); - assert_eq!(encryption_key_kms_ciphertext, None); + assert_eq!(encryption_keys.current_key, INSECURE_ENCRYPTION_KEY_HEX); + assert_eq!(encryption_keys.current_key_kms_ciphertext, None); + assert_eq!(encryption_keys.current_key_activation_block, 0); + assert!(encryption_keys.previous_key.is_none()); + assert!(encryption_keys.next_key.is_none()); } #[test] @@ -704,10 +835,10 @@ mod tests { fn encryption_key_kms_ciphertext_parses_alone() { let command = parse_start(&["--encryption-key.kms-ciphertext", "deadbeef"]) .expect("KMS ciphertext without a hex key must parse"); - let ValidatorCommand::Start { encryption_key_kms_ciphertext, .. } = command else { + let ValidatorCommand::Start { encryption_keys, .. } = command else { panic!("expected the start command"); }; - assert_eq!(encryption_key_kms_ciphertext.as_deref(), Some("deadbeef")); + assert_eq!(encryption_keys.current_key_kms_ciphertext.as_deref(), Some("deadbeef")); } #[test] @@ -724,6 +855,69 @@ mod tests { assert_eq!(error.kind(), clap::error::ErrorKind::ArgumentConflict); } + #[tokio::test] + async fn complete_manual_encryption_key_schedule_parses() { + let command = parse_start(&[ + "--encryption-key.previous.hex", + KEY_A, + "--encryption-key.previous.activation-block", + "0", + "--encryption-key.hex", + KEY_B, + "--encryption-key.activation-block", + "65536", + "--encryption-key.next.hex", + KEY_C, + "--encryption-key.next.activation-block", + "131072", + ]) + .expect("a complete previous, current, and next schedule must parse"); + let ValidatorCommand::Start { encryption_keys, .. } = command else { + panic!("expected the start command"); + }; + let provider = (*encryption_keys).into_decrypter().await.unwrap(); + let schedule = provider.encryption_key_schedule(BlockNumber::from_epoch(1)).await.unwrap(); + + let current = KeyExchangeKey::read_from_bytes(&[4; 32]).unwrap(); + let next = KeyExchangeKey::read_from_bytes(&[5; 32]).unwrap(); + assert_eq!( + schedule.current_key.key_id, + current.public_key().to_commitment().to_bytes()[..4] + ); + assert_eq!( + schedule.next_key.unwrap().key.key_id, + next.public_key().to_commitment().to_bytes()[..4] + ); + } + + #[test] + fn scheduled_key_requires_an_activation_block() { + let Err(error) = parse_start(&["--encryption-key.next.hex", KEY_C]) else { + panic!("a next key without an activation must be rejected"); + }; + assert_eq!(error.kind(), clap::error::ErrorKind::MissingRequiredArgument); + } + + #[tokio::test] + async fn startup_rejects_non_boundary_key_activation() { + let command = parse_start(&[ + "--encryption-key.previous.hex", + KEY_A, + "--encryption-key.previous.activation-block", + "0", + "--encryption-key.hex", + KEY_B, + "--encryption-key.activation-block", + "65537", + ]) + .unwrap(); + let ValidatorCommand::Start { encryption_keys, .. } = command else { + panic!("expected the start command"); + }; + + assert!((*encryption_keys).into_decrypter().await.is_err()); + } + #[test] fn storage_key_is_required() { let Err(error) = ValidatorCommand::try_parse_from(BASE_START_ARGS) else { diff --git a/bin/validator/src/lib.rs b/bin/validator/src/lib.rs index c3e67aadd5..fabe8abcd2 100644 --- a/bin/validator/src/lib.rs +++ b/bin/validator/src/lib.rs @@ -23,7 +23,9 @@ pub use server::{ValidatorAdminServer, ValidatorServer}; pub use signers::{ KmsSigner, LocalX25519TransactionInputDecrypter, + TransactionEncryptionKeySchedule, TransactionInputDecrypter, + TransactionInputDecryptionError, ValidatorSigner, 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 26af98bc5f..8e2a87c478 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 @@ -4,17 +4,20 @@ use miden_tx::utils::serde::Serializable; use super::ValidatorService; use crate::COMPONENT; +use crate::signers::TransactionEncryptionKeyInfo; #[tonic::async_trait] impl grpc::server::validator_api::GetTransactionEncryptionKey for ValidatorService { type Input = (); - type Output = grpc::transaction::TransactionEncryptionKey; + type Output = grpc::transaction::TransactionEncryptionKeyResponse; fn decode(request: ()) -> tonic::Result { Ok(request) } - fn encode(output: Self::Output) -> tonic::Result { + fn encode( + output: Self::Output, + ) -> tonic::Result { Ok(output) } @@ -30,24 +33,43 @@ impl grpc::server::validator_api::GetTransactionEncryptionKey for ValidatorServi _metadata: &tonic::metadata::MetadataMap, _extensions: &tonic::codegen::http::Extensions, ) -> tonic::Result { - // 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: 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(), + // The schedule is cached in memory and re-attested lazily at most once per epoch, so this + // endpoint remains independent of the backup serve lock. + let attested = self + .attested_encryption_key_schedule() + .await + .map_err(|err| tonic::Status::failed_precondition(err.to_string()))?; + let validator_public_key = self.signer.public_key().to_bytes(); + + let current_key = encode_key(&attested.schedule.current_key); + let next_key = attested.schedule.next_key.as_ref().map(|next| { + grpc::transaction::NextTransactionEncryptionKey { + key: Some(encode_key(&next.key)), + activation_block_num: next.activation_block_num.as_u32(), + } + }); + + Ok(grpc::transaction::TransactionEncryptionKeyResponse { + current_key: Some(current_key), + next_key, + current_key_activation_block_num: attested + .schedule + .current_key_activation_block_num + .as_u32(), + attestation_epoch: u32::from(attested.epoch), attestations: vec![grpc::transaction::ValidatorKeyAttestation { - validator_public_key: self.signer.public_key().to_bytes(), - signature: self.encryption_key_attestation.to_bytes(), + validator_public_key, + signature: attested.attestation.to_bytes(), }], - next_key: self.encryption_key_info.next_key.as_ref().map(|next| { - grpc::transaction::NextTransactionEncryptionKey { - scheme: next.scheme.as_i32(), - key_id: next.key_id.clone(), - public_key: next.public_key.clone(), - rotation_block_num: next.rotation_block_num, - } - }), }) } } + +/// Encodes one encryption key in wire format. +fn encode_key(key: &TransactionEncryptionKeyInfo) -> grpc::transaction::TransactionEncryptionKey { + grpc::transaction::TransactionEncryptionKey { + scheme: key.scheme.as_i32(), + key_id: key.key_id.clone(), + public_key: key.public_key.clone(), + } +} diff --git a/bin/validator/src/server/validator_service/mod.rs b/bin/validator/src/server/validator_service/mod.rs index 306510aed6..9cae650cff 100644 --- a/bin/validator/src/server/validator_service/mod.rs +++ b/bin/validator/src/server/validator_service/mod.rs @@ -1,9 +1,10 @@ use std::sync::Arc; use std::sync::atomic::AtomicU64; +use std::time::Duration; use miden_node_db::DatabaseError; use miden_node_db::sqlite::Database; -use miden_node_proto::domain::encryption::TransactionEncryptionKeyInfo; +use miden_node_proto::domain::encryption::TransactionEncryptionScheme; use miden_node_store::BlockStore; use miden_node_utils::tracing::{miden_instrument, miden_span_record}; use miden_protocol::Word; @@ -19,10 +20,13 @@ use miden_protocol::crypto::utils::Serializable; use miden_protocol::errors::ProposedBlockError; use miden_protocol::transaction::{TransactionHeader, TransactionId}; use tokio::sync::{Semaphore, watch}; +use tokio::time::{Instant, timeout}; use crate::db::{find_unvalidated_transactions, load_block_header, load_chain_tip}; +use crate::signers::TransactionEncryptionKeySchedule; use crate::{ COMPONENT, + LOG_TARGET, PrivateRecordChainId, PrivateRecordSealer, TransactionInputDecrypter, @@ -72,11 +76,122 @@ pub enum ValidatorError { NoGenesisHeader, #[error("failed to attest the transaction encryption key: {0}")] EncryptionKeyAttestationFailed(String), + #[error("timed out while {operation} the transaction encryption key schedule")] + EncryptionKeyScheduleRefreshTimedOut { operation: &'static str }, + #[error("transaction encryption key schedule refresh for epoch {epoch} is in backoff")] + EncryptionKeyScheduleRefreshBackoff { epoch: u16 }, + #[error("invalid transaction encryption key schedule: {0}")] + InvalidEncryptionKeySchedule(String), +} + +// ATTESTED ENCRYPTION KEY SCHEDULE +// ================================================================================ + +/// A provider schedule together with this validator's epoch-scoped attestation. +pub(crate) struct AttestedEncryptionKeySchedule { + /// The epoch in which this schedule was attested. + pub epoch: u16, + /// The complete current and optional-next schedule. + pub schedule: TransactionEncryptionKeySchedule, + /// Signature over the complete schedule and its attestation epoch. + pub attestation: Signature, +} + +impl AttestedEncryptionKeySchedule { + /// Returns the scheme of the scheduled key `key_id` names. + /// + /// A key id the schedule does not name falls back to the current key's scheme, which is what the + /// sealing transcript would have used. The provider remains the authority on whether the key id + /// is acceptable at all, so an unknown id is rejected there rather than here. + fn scheme_of(&self, key_id: &[u8]) -> TransactionEncryptionScheme { + self.schedule + .next_key + .as_ref() + .filter(|next| next.key.key_id == key_id) + .map_or(self.schedule.current_key.scheme, |next| next.key.scheme) + } +} + +struct EncryptionKeyScheduleCache { + attested: Arc, + failed_refresh: Option, + refresh: Option, +} + +struct FailedEncryptionKeyScheduleRefresh { + epoch: u16, + retry_at: Instant, +} + +struct InFlightEncryptionKeyScheduleRefresh { + epoch: u16, + state: watch::Receiver, +} + +#[derive(Clone)] +enum EncryptionKeyScheduleRefreshState { + Pending, + Complete(Result, EncryptionKeyScheduleRefreshFailure>), +} + +#[derive(Clone)] +enum EncryptionKeyScheduleRefreshFailure { + Attestation(String), + Timeout { operation: &'static str }, + InvalidSchedule(String), +} + +impl EncryptionKeyScheduleRefreshFailure { + fn from_error(error: ValidatorError) -> Self { + match error { + ValidatorError::EncryptionKeyAttestationFailed(message) => Self::Attestation(message), + ValidatorError::EncryptionKeyScheduleRefreshTimedOut { operation } => { + Self::Timeout { operation } + }, + ValidatorError::InvalidEncryptionKeySchedule(message) => Self::InvalidSchedule(message), + error => Self::Attestation(error.to_string()), + } + } + + fn into_error(self) -> ValidatorError { + match self { + Self::Attestation(message) => ValidatorError::EncryptionKeyAttestationFailed(message), + Self::Timeout { operation } => { + ValidatorError::EncryptionKeyScheduleRefreshTimedOut { operation } + }, + Self::InvalidSchedule(message) => ValidatorError::InvalidEncryptionKeySchedule(message), + } + } +} + +async fn wait_for_encryption_key_schedule_refresh( + mut state: watch::Receiver, +) -> Result, ValidatorError> { + if matches!(*state.borrow(), EncryptionKeyScheduleRefreshState::Pending) { + state.changed().await.map_err(|_| { + ValidatorError::EncryptionKeyAttestationFailed( + "transaction encryption key schedule refresh stopped".to_owned(), + ) + })?; + } + + match state.borrow().clone() { + EncryptionKeyScheduleRefreshState::Pending => { + Err(ValidatorError::EncryptionKeyAttestationFailed( + "transaction encryption key schedule refresh did not complete".to_owned(), + )) + }, + EncryptionKeyScheduleRefreshState::Complete(Ok(attested)) => Ok(attested), + EncryptionKeyScheduleRefreshState::Complete(Err(error)) => Err(error.into_error()), + } } // VALIDATOR SERVICE // ================================================================================ +const ENCRYPTION_KEY_REFRESH_RETRY_DELAY: Duration = Duration::from_secs(30); +const ENCRYPTION_KEY_REFRESH_TIMEOUT: Duration = Duration::from_secs(10); + pub(crate) struct InitialMetrics { chain_tip: u32, validated_transactions: u64, @@ -101,20 +216,19 @@ impl InitialMetrics { /// /// Implements the gRPC API for the validator. pub(crate) struct ValidatorService { - signer: ValidatorSigner, + signer: Arc, /// Decrypter for transaction inputs sealed against the shared encryption key. decrypter: Arc, - /// Commitment of the genesis block, loaded once at construction. + /// Attested provider schedule and request-path refresh backoff. + encryption_key_schedule: Arc>, + /// Bounds provider and signing calls made while refreshing the schedule. + encryption_key_refresh_timeout: Duration, + /// Commitment of the genesis block header, binding key attestations to this chain. genesis_commitment: Word, /// Public Golden key used to seal private records. private_record_sealer: PrivateRecordSealer, /// Genesis commitment bound into every private record context. private_record_chain_id: PrivateRecordChainId, - /// 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 - /// commitment, computed once at construction. - encryption_key_attestation: Signature, db: Arc, block_store: BlockStore, /// Enforces mutual exclusion between backup block subscriptions and all other RPCs. Regular @@ -156,8 +270,8 @@ impl ValidatorService { return Err(ValidatorError::ValidatorKeyNotInSet { actual: signing_key }); } - // Both keys are fixed for the process lifetime, so the attestation is computed once. This - // also keeps KMS-backed signers to a single signing call. + // Attest the provider-owned schedule before serving. The same schedule is re-attested + // lazily once per epoch for freshness, without deriving or scheduling any rotation. let genesis_commitment = db .read("load_genesis_header", |tx| load_block_header(tx, BlockNumber::GENESIS)) .await @@ -170,22 +284,29 @@ impl ValidatorService { .try_into() .expect("a Miden block commitment is always 32 bytes"), ); - let encryption_key_info = decrypter - .encryption_key() - .await - .map_err(|err| ValidatorError::EncryptionKeyAttestationFailed(err.to_string()))?; - let encryption_key_attestation = signer - .sign_commitment(encryption_key_info.attestation_commitment(genesis_commitment)) - .await - .map_err(|err| ValidatorError::EncryptionKeyAttestationFailed(err.to_string()))?; + let encryption_key_schedule = Self::attest_encryption_key_schedule( + &signer, + decrypter.as_ref(), + genesis_commitment, + BlockNumber::from(initial_metrics.chain_tip), + ENCRYPTION_KEY_REFRESH_TIMEOUT, + ) + .await?; + Ok(Self { - signer, + signer: Arc::new(signer), decrypter, + encryption_key_schedule: Arc::new(tokio::sync::Mutex::new( + EncryptionKeyScheduleCache { + attested: Arc::new(encryption_key_schedule), + failed_refresh: None, + refresh: None, + }, + )), + encryption_key_refresh_timeout: ENCRYPTION_KEY_REFRESH_TIMEOUT, genesis_commitment, private_record_sealer, private_record_chain_id, - encryption_key_info, - encryption_key_attestation, serve_lock: Arc::new(tokio::sync::RwLock::new(())), db: db.into(), block_store, @@ -196,6 +317,123 @@ impl ValidatorService { }) } + /// Fetches, validates, and signs the provider schedule effective at `chain_tip`. + async fn attest_encryption_key_schedule( + signer: &ValidatorSigner, + decrypter: &dyn TransactionInputDecrypter, + genesis_commitment: Word, + chain_tip: BlockNumber, + refresh_timeout: Duration, + ) -> Result { + let schedule = timeout(refresh_timeout, decrypter.encryption_key_schedule(chain_tip)) + .await + .map_err(|_| ValidatorError::EncryptionKeyScheduleRefreshTimedOut { + operation: "loading", + })? + .map_err(|err| ValidatorError::EncryptionKeyAttestationFailed(err.to_string()))?; + schedule + .validate_at(chain_tip) + .map_err(|err| ValidatorError::InvalidEncryptionKeySchedule(err.to_string()))?; + let epoch = chain_tip.block_epoch(); + let attestation = timeout( + refresh_timeout, + signer.sign_commitment(schedule.attestation_commitment(genesis_commitment, epoch)), + ) + .await + .map_err(|_| ValidatorError::EncryptionKeyScheduleRefreshTimedOut { operation: "signing" })? + .map_err(|err| ValidatorError::EncryptionKeyAttestationFailed(err.to_string()))?; + + Ok(AttestedEncryptionKeySchedule { epoch, schedule, attestation }) + } + + /// Returns an epoch-fresh attestation without changing provider rotation policy. + pub(crate) async fn attested_encryption_key_schedule( + &self, + ) -> Result, ValidatorError> { + loop { + let mut cached = self.encryption_key_schedule.lock().await; + let chain_tip = *self.committed_tip.borrow(); + let epoch = chain_tip.block_epoch(); + if cached.attested.epoch == epoch { + return Ok(Arc::clone(&cached.attested)); + } + if cached + .failed_refresh + .as_ref() + .is_some_and(|failure| failure.epoch == epoch && Instant::now() < failure.retry_at) + { + return Err(ValidatorError::EncryptionKeyScheduleRefreshBackoff { epoch }); + } + if let Some(refresh) = &cached.refresh { + let refresh_epoch = refresh.epoch; + let state = refresh.state.clone(); + drop(cached); + let result = wait_for_encryption_key_schedule_refresh(state).await; + if refresh_epoch == epoch { + return result; + } + continue; + } + + let (state_tx, state_rx) = watch::channel(EncryptionKeyScheduleRefreshState::Pending); + cached.refresh = + Some(InFlightEncryptionKeyScheduleRefresh { epoch, state: state_rx.clone() }); + drop(cached); + + let signer = Arc::clone(&self.signer); + let decrypter = Arc::clone(&self.decrypter); + let encryption_key_schedule = Arc::clone(&self.encryption_key_schedule); + let genesis_commitment = self.genesis_commitment; + let refresh_timeout = self.encryption_key_refresh_timeout; + let refresh = tokio::spawn(async move { + Self::attest_encryption_key_schedule( + &signer, + decrypter.as_ref(), + genesis_commitment, + chain_tip, + refresh_timeout, + ) + .await + }); + tokio::spawn(async move { + let result = match refresh.await { + Ok(result) => result, + Err(err) => Err(ValidatorError::EncryptionKeyAttestationFailed(format!( + "transaction encryption key schedule refresh task failed: {err}" + ))), + }; + let mut cached = encryption_key_schedule.lock().await; + let state = match result { + Ok(attested) => { + cached.failed_refresh = None; + let attested = Arc::new(attested); + tracing::info!( + target: LOG_TARGET, + epoch = attested.epoch, + key_id = %hex::encode(&attested.schedule.current_key.key_id), + "Refreshed the transaction encryption key schedule attestation" + ); + cached.attested = Arc::clone(&attested); + EncryptionKeyScheduleRefreshState::Complete(Ok(attested)) + }, + Err(err) => { + cached.failed_refresh = Some(FailedEncryptionKeyScheduleRefresh { + epoch, + retry_at: Instant::now() + ENCRYPTION_KEY_REFRESH_RETRY_DELAY, + }); + EncryptionKeyScheduleRefreshState::Complete(Err( + EncryptionKeyScheduleRefreshFailure::from_error(err), + )) + }, + }; + cached.refresh = None; + state_tx.send_replace(state); + }); + + return wait_for_encryption_key_schedule_refresh(state_rx).await; + } + } + /// Validates a proposed block by checking: /// 1. All transactions have been previously validated by this validator. /// 2. The block header can be successfully built from the proposed block. 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 5f3d549ffe..c6c6094927 100644 --- a/bin/validator/src/server/validator_service/submit_proven_transaction.rs +++ b/bin/validator/src/server/validator_service/submit_proven_transaction.rs @@ -126,35 +126,53 @@ impl ValidatorService { 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", - )); - } + // The key the inputs were sealed against is whichever one the client held, which during a + // rotation may be the previous, current or next key. The provider owns that decision, so + // the key id is passed through to it rather than compared against a single key here. + let attested = self + .attested_encryption_key_schedule() + .await + .map_err(|err| Status::failed_precondition(err.to_string()))?; + let chain_tip = *self.committed_tip.borrow(); + let scheme = attested.scheme_of(&sealed.key_id).as_u32(); let associated_data = transaction_inputs_associated_data( - self.encryption_key_info.scheme.as_u32(), - &self.encryption_key_info.key_id, + scheme, + &sealed.key_id, self.genesis_commitment, tx_id, ); let plaintext = self .decrypter - .decrypt_transaction_inputs(&sealed.ciphertext, &associated_data) + .decrypt_transaction_inputs( + &sealed.key_id, + chain_tip, + &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:#}" - )) + use crate::TransactionInputDecryptionError as DecryptionError; + + // A rejected key id is actionable: the client can refetch the schedule and reseal. + // Deliberately does not echo this validator's own key ids, because the RPC relays + // this status verbatim to the submitting client. An authentication failure, by + // contrast, collapses a wrong key, tampered ciphertext, mismatched associated data + // and corrupt framing into one error, so it 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`. + match err { + DecryptionError::PrematureKey { .. } + | DecryptionError::ExpiredKey { .. } + | DecryptionError::UnknownKey { .. } => Status::failed_precondition( + "Transaction inputs were sealed against an encryption key that is not \ + currently accepted: re-fetch the key with GetTransactionEncryptionKey and \ + seal the inputs again", + ), + err => Status::invalid_argument(format!( + "Failed to unseal the transaction inputs: {err:#}" + )), + } })?; TransactionInputs::read_from_bytes(&plaintext).map_err(|err| { diff --git a/bin/validator/src/server/validator_service/tests.rs b/bin/validator/src/server/validator_service/tests.rs index 1df0240be4..7c647c1bab 100644 --- a/bin/validator/src/server/validator_service/tests.rs +++ b/bin/validator/src/server/validator_service/tests.rs @@ -1,10 +1,13 @@ use std::collections::BTreeMap; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::time::Duration; use miden_node_proto::domain::encryption::{ TransactionEncryptionScheme, TrustedTransactionEncryptionState, transaction_inputs_associated_data, - verify_transaction_encryption_key, + verify_transaction_encryption_key_schedule, }; use miden_node_proto::generated::{self as proto}; use miden_node_proto::server::validator_api; @@ -61,9 +64,64 @@ const TEST_ENCRYPTION_SECRET: [u8; 32] = [3u8; 32]; /// Creates a [`LocalX25519TransactionInputDecrypter`] from the shared test secret, modelling the /// identically provisioned encryption key of a validator in the set. fn test_decrypter() -> LocalX25519TransactionInputDecrypter { - let key = KeyExchangeKey::read_from_bytes(&TEST_ENCRYPTION_SECRET) - .expect("test secret should be a valid key exchange key"); - LocalX25519TransactionInputDecrypter::new(key) + LocalX25519TransactionInputDecrypter::new( + KeyExchangeKey::read_from_bytes(&TEST_ENCRYPTION_SECRET).unwrap(), + ) +} + +struct FailingScheduleProvider { + inner: LocalX25519TransactionInputDecrypter, + schedule_calls: AtomicUsize, + fail_schedule: AtomicBool, + panic_schedule: AtomicBool, + block_schedule: AtomicBool, + schedule_started: tokio::sync::Notify, + schedule_released: tokio::sync::Notify, +} + +impl FailingScheduleProvider { + fn new() -> Self { + Self { + inner: test_decrypter(), + schedule_calls: AtomicUsize::new(0), + fail_schedule: AtomicBool::new(false), + panic_schedule: AtomicBool::new(false), + block_schedule: AtomicBool::new(false), + schedule_started: tokio::sync::Notify::new(), + schedule_released: tokio::sync::Notify::new(), + } + } +} + +#[tonic::async_trait] +impl TransactionInputDecrypter for FailingScheduleProvider { + async fn encryption_key_schedule( + &self, + chain_tip: BlockNumber, + ) -> anyhow::Result { + self.schedule_calls.fetch_add(1, Ordering::SeqCst); + if self.fail_schedule.load(Ordering::SeqCst) { + anyhow::bail!("schedule provider unavailable"); + } + assert!(!self.panic_schedule.load(Ordering::SeqCst), "schedule provider panicked"); + if self.block_schedule.load(Ordering::SeqCst) { + self.schedule_started.notify_one(); + self.schedule_released.notified().await; + } + self.inner.encryption_key_schedule(chain_tip).await + } + + async fn decrypt_transaction_inputs( + &self, + key_id: &[u8], + chain_tip: BlockNumber, + ciphertext: &[u8], + associated_data: &[u8], + ) -> Result, crate::TransactionInputDecryptionError> { + self.inner + .decrypt_transaction_inputs(key_id, chain_tip, ciphertext, associated_data) + .await + } } /// Test harness that wraps a [`Validator`] and tracks the chain MMR state needed to construct valid @@ -81,6 +139,10 @@ impl TestValidator { /// Creates a correctly configured [`ValidatorService`]: the validator signs blocks with the /// same key that is designated as the `validator_key` in the genesis block. async fn new() -> Self { + Self::new_with_decrypter(Arc::new(test_decrypter())).await + } + + async fn new_with_decrypter(decrypter: Arc) -> Self { let key = random_secret_key(); let signer = ValidatorSigner::new_local(key.clone()); let (temp_dir, db, block_store, genesis_header) = setup_db_with_genesis(&key).await; @@ -88,7 +150,7 @@ impl TestValidator { Self { server: ValidatorService::new( signer, - std::sync::Arc::new(test_decrypter()), + decrypter, PrivateRecordSealer::from_operator_key(&operator_keys().remove(0)), db, block_store, @@ -120,14 +182,31 @@ impl TestValidator { validator_api::SubmitProvenTransaction::full(&self.server, request).await } + /// Returns the opaque id of the key this validator currently serves. + async fn current_key_id(&self) -> Vec { + self.server + .attested_encryption_key_schedule() + .await + .expect("the test schedule should attest") + .schedule + .current_key + .key_id + .clone() + } + /// 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( + async fn seal( &self, tx_id: TransactionId, plaintext: &[u8], ) -> proto::transaction::SealedTransactionInputs { - let key = &self.server.encryption_key_info; + let attested = self + .server + .attested_encryption_key_schedule() + .await + .expect("the test schedule should attest"); + let key = attested.schedule.current_key.clone(); let associated_data = transaction_inputs_associated_data( key.scheme.as_u32(), &key.key_id, @@ -218,7 +297,7 @@ impl TestValidator { /// Calls the `get_transaction_encryption_key` endpoint on the validator server. async fn call_get_transaction_encryption_key( &self, - ) -> proto::transaction::TransactionEncryptionKey { + ) -> proto::transaction::TransactionEncryptionKeyResponse { validator_api::GetTransactionEncryptionKey::full(&self.server, tonic::Request::new(())) .await .expect("encryption key should always be available") @@ -948,21 +1027,21 @@ async fn requests_run_concurrently() { // TRANSACTION ENCRYPTION KEY // ================================================================================================ -/// The endpoint returns the shared encryption key attested by this validator's own signing key. The -/// signature verifies over a commitment recomputed from the response fields and the chain's genesis -/// commitment, so a client needs nothing beyond the response and the chain data it already trusts. +/// The endpoint returns one complete provider schedule attested by this validator. The shared +/// verifier consumes only the response and chain state already trusted by the caller. #[tokio::test] -async fn transaction_encryption_key_is_attested() { +async fn transaction_encryption_key_schedule_is_attested() { let tv = TestValidator::new().await; - // The chain has not advanced, so the chain tip is the genesis header. - let genesis = tv.chain_tip.commitment(); let response = tv.call_get_transaction_encryption_key().await; - - let info = test_decrypter().encryption_key().await.expect("key info should be available"); - 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 expected = test_decrypter() + .encryption_key_schedule(tv.chain_tip.block_num()) + .await + .unwrap(); + let current_key = response.current_key.as_ref().expect("response must carry a current key"); + let scheme = TransactionEncryptionScheme::try_from(current_key.scheme).unwrap(); + assert_eq!(scheme, expected.current_key.scheme); + assert_eq!(current_key.key_id, expected.current_key.key_id); + assert_eq!(current_key.public_key, expected.current_key.public_key); let [attestation] = response.attestations.as_slice() else { panic!("response must carry exactly the serving validator's attestation"); @@ -972,80 +1051,283 @@ async fn transaction_encryption_key_is_attested() { tv.server.signer.public_key().to_bytes(), "attestation must identify the serving validator", ); + let trusted_keys = [tv.server.signer.public_key()]; - let verified = verify_transaction_encryption_key( - response, - TrustedTransactionEncryptionState::new(genesis, &trusted_keys), + let verified = verify_transaction_encryption_key_schedule( + &response, + TrustedTransactionEncryptionState::new( + tv.chain_tip.commitment(), + tv.chain_tip.block_num(), + &trusted_keys, + ), ) .expect("attestation must verify against this validator's signing key"); - assert_eq!(verified.info(), &info); + assert_eq!(verified.schedule(), &expected); } -/// Two validators provisioned with the same shared encryption secret but distinct signing keys -/// return identical public key material with different signatures. +/// Validators sharing an encryption provider return the same schedule but attest it with their own +/// chain-recognized signing keys. #[tokio::test] -async fn shared_key_is_attested_per_validator() { +async fn shared_schedule_is_attested_per_validator() { let tv_a = TestValidator::new().await; let tv_b = TestValidator::new().await; let response_a = tv_a.call_get_transaction_encryption_key().await; let response_b = tv_b.call_get_transaction_encryption_key().await; - assert_eq!(response_a.scheme, response_b.scheme); - assert_eq!(response_a.key_id, response_b.key_id); - assert_eq!(response_a.public_key, response_b.public_key); + assert_eq!(response_a.current_key, response_b.current_key); + assert_eq!( + response_a.current_key_activation_block_num, + response_b.current_key_activation_block_num + ); + assert_eq!(response_a.next_key, response_b.next_key); assert_ne!( response_a.attestations[0].signature, response_b.attestations[0].signature, "each validator must attest with its own signing key", ); } -/// The attestation signature must not survive tampering with any field of the response, nor a -/// swapped chain. +/// Changing a signed field invalidates the single schedule-level attestation. #[tokio::test] -async fn tampered_attestation_fails_verification() { +async fn tampered_schedule_fails_shared_verification() { let tv = TestValidator::new().await; let genesis = tv.chain_tip.commitment(); let response = tv.call_get_transaction_encryption_key().await; let trusted_keys = [tv.server.signer.public_key()]; - let trusted = TrustedTransactionEncryptionState::new(genesis, &trusted_keys); + let trusted = + TrustedTransactionEncryptionState::new(genesis, tv.chain_tip.block_num(), &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; + changed_key_id.current_key.as_mut().unwrap().key_id[0] ^= 0x01; let mut changed_public_key = response.clone(); - changed_public_key.public_key = + changed_public_key.current_key.as_mut().unwrap().public_key = KeyExchangeKey::read_from_bytes(&[4u8; 32]).unwrap().public_key().to_bytes(); + // Injecting a scheduled rotation into a schedule attested without one must also break the + // signature, which is what makes the next key impossible to add or strip in transit. 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, + key: Some(proto::transaction::TransactionEncryptionKey { + scheme: TransactionEncryptionScheme::X25519XChaCha20Poly1305.as_i32(), + key_id: vec![9, 9, 9, 9], + public_key: KeyExchangeKey::read_from_bytes(&[5u8; 32]) + .unwrap() + .public_key() + .to_bytes(), + }), + activation_block_num: BlockNumber::from_epoch(1).as_u32(), }); - for tampered in [changed_scheme, changed_key_id, changed_public_key, injected_next_key] { + for tampered in [changed_key_id, changed_public_key, injected_next_key] { assert!( - verify_transaction_encryption_key(tampered, trusted).is_err(), + verify_transaction_encryption_key_schedule(&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), + verify_transaction_encryption_key_schedule( + &response, + TrustedTransactionEncryptionState::new( + tampered_genesis, + tv.chain_tip.block_num(), + &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 -/// validator holding the shared secret can unseal. Unsealing must reject mismatched associated -/// data. +/// A fixed provider key remains current across epochs while the validator refreshes only the +/// schedule's freshness attestation. +#[tokio::test] +async fn schedule_is_reattested_without_automatic_rotation() { + let tv = TestValidator::new().await; + let before = tv.call_get_transaction_encryption_key().await; + + tv.server.committed_tip.send_replace(BlockNumber::from_epoch(1)); + let after = tv.call_get_transaction_encryption_key().await; + + assert_eq!(before.current_key, after.current_key); + assert_eq!(before.next_key, after.next_key); + assert_eq!(before.attestation_epoch, 0); + assert_eq!(after.attestation_epoch, 1); + assert_ne!(before.attestations[0].signature, after.attestations[0].signature); + + let validator_keys = [tv.server.signer.public_key()]; + let trusted = TrustedTransactionEncryptionState::new( + tv.chain_tip.commitment(), + BlockNumber::from_epoch(1), + &validator_keys, + ); + verify_transaction_encryption_key_schedule(&after, trusted).unwrap(); + assert!(verify_transaction_encryption_key_schedule(&before, trusted).is_err()); +} + +/// A request that waits on the cache lock reads the chain tip after the lock is acquired, so it +/// cannot replace a newer attestation with one for an older epoch. +#[tokio::test] +async fn stale_request_cannot_roll_back_schedule_attestation() { + let tv = TestValidator::new().await; + let epoch_one = ValidatorService::attest_encryption_key_schedule( + tv.server.signer.as_ref(), + tv.server.decrypter.as_ref(), + tv.server.genesis_commitment, + BlockNumber::from_epoch(1), + tv.server.encryption_key_refresh_timeout, + ) + .await + .unwrap(); + + let mut cached = tv.server.encryption_key_schedule.lock().await; + let mut stale_request = Box::pin(tv.server.attested_encryption_key_schedule()); + tokio::select! { + biased; + _ = &mut stale_request => panic!("request unexpectedly completed"), + () = tokio::task::yield_now() => {}, + } + + tv.server.committed_tip.send_replace(BlockNumber::from_epoch(1)); + cached.attested = Arc::new(epoch_one); + drop(cached); + + let attested = stale_request.await.unwrap(); + assert_eq!(attested.epoch, 1); + assert_eq!(tv.server.encryption_key_schedule.lock().await.attested.epoch, 1); +} + +/// A failed epoch refresh is retried only after a request-path backoff, avoiding repeated provider +/// or KMS calls during an outage without introducing a background rotation worker. +#[tokio::test] +async fn failed_schedule_refresh_is_backed_off() { + let provider = Arc::new(FailingScheduleProvider::new()); + let tv = TestValidator::new_with_decrypter(provider.clone()).await; + assert_eq!(provider.schedule_calls.load(Ordering::SeqCst), 1); + + provider.fail_schedule.store(true, Ordering::SeqCst); + tv.server.committed_tip.send_replace(BlockNumber::from_epoch(1)); + + assert!(matches!( + tv.server.attested_encryption_key_schedule().await, + Err(ValidatorError::EncryptionKeyAttestationFailed(_)) + )); + assert!(matches!( + tv.server.attested_encryption_key_schedule().await, + Err(ValidatorError::EncryptionKeyScheduleRefreshBackoff { epoch: 1 }) + )); + assert_eq!( + provider.schedule_calls.load(Ordering::SeqCst), + 2, + "the initial load and first failed refresh should be the only provider calls", + ); +} + +#[tokio::test] +async fn panicked_schedule_refresh_is_backed_off_without_wedging_cache() { + let provider = Arc::new(FailingScheduleProvider::new()); + let tv = TestValidator::new_with_decrypter(provider.clone()).await; + + provider.panic_schedule.store(true, Ordering::SeqCst); + tv.server.committed_tip.send_replace(BlockNumber::from_epoch(1)); + assert!(matches!( + tv.server.attested_encryption_key_schedule().await, + Err(ValidatorError::EncryptionKeyAttestationFailed(message)) + if message.contains("refresh task failed") + )); + assert!(matches!( + tv.server.attested_encryption_key_schedule().await, + Err(ValidatorError::EncryptionKeyScheduleRefreshBackoff { epoch: 1 }) + )); + + provider.panic_schedule.store(false, Ordering::SeqCst); + tv.server.committed_tip.send_replace(BlockNumber::from_epoch(2)); + tv.server.attested_encryption_key_schedule().await.unwrap(); + assert_eq!(provider.schedule_calls.load(Ordering::SeqCst), 3); +} + +/// A client cancellation does not penalize the next request. +#[tokio::test] +async fn cancelled_schedule_refresh_allows_immediate_retry() { + let provider = Arc::new(FailingScheduleProvider::new()); + let tv = TestValidator::new_with_decrypter(provider.clone()).await; + + provider.block_schedule.store(true, Ordering::SeqCst); + tv.server.committed_tip.send_replace(BlockNumber::from_epoch(1)); + + let mut refresh = Box::pin(tv.server.attested_encryption_key_schedule()); + tokio::select! { + () = provider.schedule_started.notified() => {}, + _ = &mut refresh => panic!("refresh unexpectedly completed"), + } + drop(refresh); + + provider.block_schedule.store(false, Ordering::SeqCst); + provider.schedule_released.notify_one(); + tv.server.attested_encryption_key_schedule().await.unwrap(); + assert_eq!( + provider.schedule_calls.load(Ordering::SeqCst), + 2, + "the successful retry must share the refresh started by the cancelled request", + ); +} + +#[tokio::test] +async fn failed_signer_refresh_is_backed_off() { + let mut tv = TestValidator::new().await; + let public_key = tv.server.signer.public_key(); + tv.server.signer = Arc::new(ValidatorSigner::new_failing(public_key)); + tv.server.committed_tip.send_replace(BlockNumber::from_epoch(1)); + + assert!(matches!( + tv.server.attested_encryption_key_schedule().await, + Err(ValidatorError::EncryptionKeyAttestationFailed(_)) + )); + assert!(matches!( + tv.server.attested_encryption_key_schedule().await, + Err(ValidatorError::EncryptionKeyScheduleRefreshBackoff { epoch: 1 }) + )); +} + +#[tokio::test] +async fn schedule_lookup_timeout_is_backed_off() { + let provider = Arc::new(FailingScheduleProvider::new()); + let mut tv = TestValidator::new_with_decrypter(provider.clone()).await; + tv.server.encryption_key_refresh_timeout = Duration::from_millis(1); + provider.block_schedule.store(true, Ordering::SeqCst); + tv.server.committed_tip.send_replace(BlockNumber::from_epoch(1)); + + assert!(matches!( + tv.server.attested_encryption_key_schedule().await, + Err(ValidatorError::EncryptionKeyScheduleRefreshTimedOut { operation: "loading" }) + )); + assert!(matches!( + tv.server.attested_encryption_key_schedule().await, + Err(ValidatorError::EncryptionKeyScheduleRefreshBackoff { epoch: 1 }) + )); + assert_eq!(provider.schedule_calls.load(Ordering::SeqCst), 2); +} + +#[tokio::test] +async fn schedule_signing_timeout_is_backed_off() { + let mut tv = TestValidator::new().await; + tv.server.encryption_key_refresh_timeout = Duration::from_millis(1); + let public_key = tv.server.signer.public_key(); + tv.server.signer = Arc::new(ValidatorSigner::new_blocking(public_key)); + tv.server.committed_tip.send_replace(BlockNumber::from_epoch(1)); + + assert!(matches!( + tv.server.attested_encryption_key_schedule().await, + Err(ValidatorError::EncryptionKeyScheduleRefreshTimedOut { operation: "signing" }) + )); + assert!(matches!( + tv.server.attested_encryption_key_schedule().await, + Err(ValidatorError::EncryptionKeyScheduleRefreshBackoff { epoch: 1 }) + )); +} + +/// A client can reconstruct the sealing key from the response and the provider can decrypt the +/// ciphertext selected by the caller-supplied opaque key id. #[tokio::test] async fn response_key_seals_for_the_validator_set() { use miden_protocol::crypto::dsa::eddsa_25519_sha512::PublicKey as EncryptionPublicKey; @@ -1053,47 +1335,38 @@ async fn response_key_seals_for_the_validator_set() { let tv = TestValidator::new().await; let response = tv.call_get_transaction_encryption_key().await; + let current = response.current_key.unwrap(); - let public_key = EncryptionPublicKey::read_from_bytes(&response.public_key) + let public_key = EncryptionPublicKey::read_from_bytes(¤t.public_key) .expect("response public key should deserialize"); let sealing_key = SealingKey::X25519XChaCha20Poly1305(public_key); - - let mut rng = rand::rng(); - let plaintext = b"transaction inputs"; let associated_data = b"scheme|key_id|chain|tx"; let sealed = sealing_key - .seal_bytes_with_associated_data(&mut rng, plaintext, associated_data) - .unwrap(); + .seal_bytes_with_associated_data(&mut rand::rng(), b"transaction inputs", associated_data) + .unwrap() + .to_bytes(); - let sealed = sealed.to_bytes(); let opened = test_decrypter() - .decrypt_transaction_inputs(&sealed, associated_data) + .decrypt_transaction_inputs( + ¤t.key_id, + tv.chain_tip.block_num(), + &sealed, + associated_data, + ) .await .unwrap(); - assert_eq!(opened.as_slice(), plaintext); - - assert!( - test_decrypter() - .decrypt_transaction_inputs(&sealed, b"other associated data") - .await - .is_err(), - "decryption must fail under mismatched associated data", - ); + assert_eq!(opened, b"transaction inputs"); } -/// Like `status`, the encryption key stays available while a backup subscription holds the -/// exclusive serve lock. +/// Like status, the encryption key remains available during an exclusive backup subscription. #[tokio::test] async fn encryption_key_available_during_backup() { let mut tv = TestValidator::new().await; tv.apply_empty_block().await; - let stream = tv.call_block_subscription(1).await; - // `call_get_transaction_encryption_key` panics on rejection, so completing proves availability - // during the backup. let response = tv.call_get_transaction_encryption_key().await; - assert!(!response.public_key.is_empty()); + assert!(!response.current_key.unwrap().public_key.is_empty()); drop(stream); } @@ -1127,7 +1400,7 @@ 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(), + key_id: tv.current_key_id().await, ciphertext: b"not a sealed message, just bytes".to_vec(), }; @@ -1145,7 +1418,7 @@ async fn submit_rejects_plaintext_inputs() { 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"); + let mut sealed = tv.seal(tx.id(), b"transaction inputs").await; sealed.key_id = vec![0xAA, 0xBB, 0xCC, 0xDD]; let status = tv.call_submit_proven_transaction(&tx, sealed).await.unwrap_err(); @@ -1157,7 +1430,7 @@ async fn submit_rejects_unknown_key_id() { status.message(), ); // This status reaches the client verbatim through the RPC. - let own_key_id = hex::encode(&tv.server.encryption_key_info.key_id); + let own_key_id = hex::encode(tv.current_key_id().await); assert!( !status.message().contains(&own_key_id), "the rejection must not echo the validator's key id", @@ -1175,7 +1448,7 @@ async fn submit_rejects_inputs_sealed_for_a_different_transaction() { 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 sealed_for_a = tv.seal(tx_a.id(), b"transaction inputs").await; let status = tv.call_submit_proven_transaction(&tx_b, sealed_for_a).await.unwrap_err(); @@ -1190,7 +1463,7 @@ async fn submit_rejects_inputs_sealed_for_a_different_transaction() { 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 sealed = tv.seal(tx.id(), b"not really transaction inputs").await; let status = tv.call_submit_proven_transaction(&tx, sealed).await.unwrap_err(); @@ -1214,7 +1487,7 @@ async fn failed_proof_verification_does_not_store_inputs() { let tv = TestValidator::new().await; let tx = dummy_proven_tx(11); let fixture = proven_transaction_fixture().await; - let sealed = tv.seal(tx.id(), &fixture.inputs.to_bytes()); + let sealed = tv.seal(tx.id(), &fixture.inputs.to_bytes()).await; let status = tv.call_submit_proven_transaction(&tx, sealed).await.unwrap_err(); @@ -1229,7 +1502,7 @@ async fn failed_reexecution_does_not_store_inputs() { let tv = TestValidator::new().await; let fixture = proven_transaction_fixture().await; let tx = &fixture.transaction; - let sealed = tv.seal(tx.id(), &fixture.execution_failure_inputs.to_bytes()); + let sealed = tv.seal(tx.id(), &fixture.execution_failure_inputs.to_bytes()).await; let status = tv.call_submit_proven_transaction(tx, sealed).await.unwrap_err(); @@ -1244,7 +1517,7 @@ async fn header_mismatch_does_not_store_inputs() { let tv = TestValidator::new().await; let fixture = proven_transaction_fixture().await; let tx = &fixture.transaction; - let sealed = tv.seal(tx.id(), &fixture.mismatch_inputs.to_bytes()); + let sealed = tv.seal(tx.id(), &fixture.mismatch_inputs.to_bytes()).await; let status = tv.call_submit_proven_transaction(tx, sealed).await.unwrap_err(); @@ -1259,8 +1532,8 @@ async fn valid_submission_stores_one_protected_record() { let tv = TestValidator::new().await; let fixture = proven_transaction_fixture().await; let tx = &fixture.transaction; - let first = tv.seal(tx.id(), &fixture.inputs.to_bytes()); - let second = tv.seal(tx.id(), &fixture.inputs.to_bytes()); + let first = tv.seal(tx.id(), &fixture.inputs.to_bytes()).await; + let second = tv.seal(tx.id(), &fixture.inputs.to_bytes()).await; assert_ne!(first.ciphertext, second.ciphertext); tv.call_submit_proven_transaction(tx, first.clone()).await.unwrap(); @@ -1296,13 +1569,16 @@ async fn failed_batch_item_does_not_store_inputs() { let valid_tx = &fixture.transaction; let rejected_tx = dummy_proven_tx(12); - tv.call_submit_proven_transaction(valid_tx, tv.seal(valid_tx.id(), &fixture.inputs.to_bytes())) - .await - .unwrap(); + tv.call_submit_proven_transaction( + valid_tx, + tv.seal(valid_tx.id(), &fixture.inputs.to_bytes()).await, + ) + .await + .unwrap(); let status = tv .call_submit_proven_transaction( &rejected_tx, - tv.seal(rejected_tx.id(), &fixture.inputs.to_bytes()), + tv.seal(rejected_tx.id(), &fixture.inputs.to_bytes()).await, ) .await .unwrap_err(); diff --git a/bin/validator/src/signers/mod.rs b/bin/validator/src/signers/mod.rs index 829ce52bd0..aa924cf3f1 100644 --- a/bin/validator/src/signers/mod.rs +++ b/bin/validator/src/signers/mod.rs @@ -1,11 +1,15 @@ mod kms; + pub use kms::{KmsSigner, decrypt_key_material}; -use miden_node_proto::domain::encryption::{ +pub use miden_node_proto::domain::encryption::{ + NextTransactionEncryptionKey, TransactionEncryptionKeyInfo, + TransactionEncryptionKeySchedule, TransactionEncryptionScheme, }; use miden_node_utils::spawn::spawn_blocking_in_current_span; use miden_protocol::Word; +use miden_protocol::block::BlockNumber; use miden_protocol::crypto::dsa::ecdsa_k256_keccak::{PublicKey, Signature, SigningKey}; use miden_protocol::crypto::dsa::eddsa_25519_sha512::{ KeyExchangeKey, @@ -23,6 +27,10 @@ use miden_protocol::utils::serde::{Deserializable, Serializable}; pub enum ValidatorSigner { Kms(KmsSigner), Local(SigningKey), + #[cfg(test)] + Failing(PublicKey), + #[cfg(test)] + Blocking(PublicKey), } impl ValidatorSigner { @@ -40,11 +48,23 @@ impl ValidatorSigner { Self::Local(secret_key) } + #[cfg(test)] + pub(crate) fn new_failing(public_key: PublicKey) -> Self { + Self::Failing(public_key) + } + + #[cfg(test)] + pub(crate) fn new_blocking(public_key: PublicKey) -> Self { + Self::Blocking(public_key) + } + /// Returns the public key corresponding to the configured signer. pub fn public_key(&self) -> PublicKey { match self { Self::Kms(signer) => signer.public_key(), Self::Local(signer) => signer.public_key(), + #[cfg(test)] + Self::Failing(public_key) | Self::Blocking(public_key) => public_key.clone(), } } @@ -58,6 +78,10 @@ impl ValidatorSigner { }) .await .unwrap_or_else(|e| std::panic::resume_unwind(e.into_panic())), + #[cfg(test)] + Self::Failing(_) => anyhow::bail!("test signer unavailable"), + #[cfg(test)] + Self::Blocking(_) => std::future::pending::().await, }; Ok(signature) @@ -67,35 +91,91 @@ impl ValidatorSigner { // TRANSACTION INPUT DECRYPTER // ================================================================================================= -/// Decryption counterpart to [`ValidatorSigner`] for the shared transaction encryption -/// (submission) key. +/// Operation-only provider for transaction input encryption keys. /// -/// Unlike the signing key, the key material behind an implementation must be identical across -/// every validator in the set. This lets any validator unseal an encrypted submission, regardless -/// of which validator attested the encryption key to the client. +/// Unlike the signing key, the key material behind an implementation must be identical across every +/// validator in the set. This lets any validator unseal an encrypted submission, regardless of which +/// validator attested the encryption key schedule to the client. /// -/// The interface deliberately does not assume that secret key bytes exist in the validator -/// process: an implementation may hold a local secret (see -/// [`LocalX25519TransactionInputDecrypter`]) or delegate decryption to an external system such as -/// a TEE that only exposes a decrypt operation. +/// Implementations own key identifiers, scheduling, grace policy, and secret storage. The validator +/// only requests public schedule metadata and asks the provider to decrypt with the key identifier +/// carried by a submission. The interface deliberately does not assume that secret key bytes exist +/// in the validator process: an implementation may hold a local secret (see +/// [`LocalX25519TransactionInputDecrypter`]) or delegate decryption to an external system such as a +/// TEE that only exposes a decrypt operation. #[tonic::async_trait] pub trait TransactionInputDecrypter: Send + Sync { - /// Returns the public metadata of the current encryption key. - async fn encryption_key(&self) -> anyhow::Result; + /// Returns the schedule effective at `chain_tip`. + /// + /// Providers must keep this schedule unchanged within an epoch. Manual schedule updates happen + /// at epoch boundaries so an older attestation from the same epoch cannot suppress a newly + /// announced key. + async fn encryption_key_schedule( + &self, + chain_tip: BlockNumber, + ) -> anyhow::Result; - /// Decrypts transaction inputs sealed against the current encryption key. + /// Decrypts inputs using the caller-supplied opaque key identifier. /// - /// The ciphertext is a serialized [`SealedMessage`]. + /// The ciphertext is a serialized [`SealedMessage`]. The provider must distinguish an announced + /// key that is not active yet, an expired grace key, and an identifier it does not own. async fn decrypt_transaction_inputs( &self, + key_id: &[u8], + chain_tip: BlockNumber, ciphertext: &[u8], associated_data: &[u8], - ) -> anyhow::Result>; + ) -> Result, TransactionInputDecryptionError>; } -/// [`TransactionInputDecrypter`] backed by a locally provisioned X25519 shared secret. -pub struct LocalX25519TransactionInputDecrypter { +/// Failure to decrypt sealed transaction inputs. +#[derive(Debug, thiserror::Error)] +pub enum TransactionInputDecryptionError { + #[error("transaction encryption key {key_id} does not activate until block {activation}")] + PrematureKey { key_id: String, activation: BlockNumber }, + #[error("transaction encryption key {key_id} expired at block {expired_at}")] + ExpiredKey { key_id: String, expired_at: BlockNumber }, + #[error("unknown transaction encryption key {key_id}")] + UnknownKey { key_id: String }, + #[error("failed to deserialize the sealed transaction inputs")] + InvalidCiphertext(#[source] anyhow::Error), + #[error("failed to decrypt the transaction inputs")] + DecryptionFailed(#[source] anyhow::Error), +} + +#[derive(Clone)] +struct LocalEncryptionKey { secret_key: KeyExchangeKey, + info: TransactionEncryptionKeyInfo, +} + +impl LocalEncryptionKey { + fn new(secret_key: KeyExchangeKey) -> Self { + let public_key = secret_key.public_key(); + let info = TransactionEncryptionKeyInfo { + scheme: LocalX25519TransactionInputDecrypter::SCHEME, + key_id: key_id_of(&public_key), + public_key: public_key.to_bytes(), + }; + Self { secret_key, info } + } +} + +#[derive(Clone)] +struct ScheduledLocalEncryptionKey { + key: LocalEncryptionKey, + activation_block_num: BlockNumber, +} + +/// Local X25519 provider with an optional manually scheduled replacement key. +/// +/// Constructing this provider does not derive keys or choose a rotation cadence. When a next key +/// is configured, its declared epoch-boundary activation is enforced from the trusted chain tip. +/// One previous key may be retained for decryption through the current key's activation epoch. +pub struct LocalX25519TransactionInputDecrypter { + previous: Option, + current: ScheduledLocalEncryptionKey, + next: Option, } impl LocalX25519TransactionInputDecrypter { @@ -103,136 +183,700 @@ impl LocalX25519TransactionInputDecrypter { pub const SCHEME: TransactionEncryptionScheme = TransactionEncryptionScheme::X25519XChaCha20Poly1305; - /// Constructs a decrypter from a locally provisioned shared secret. + /// Constructs a provider with one key active since genesis and no scheduled rotation. pub fn new(secret_key: KeyExchangeKey) -> Self { - Self { secret_key } + Self { + previous: None, + current: ScheduledLocalEncryptionKey { + key: LocalEncryptionKey::new(secret_key), + activation_block_num: BlockNumber::GENESIS, + }, + next: None, + } + } + + /// Constructs a complete manual key schedule for startup. + pub fn from_schedule( + previous: Option<(KeyExchangeKey, BlockNumber)>, + current: (KeyExchangeKey, BlockNumber), + next: Option<(KeyExchangeKey, BlockNumber)>, + ) -> anyhow::Result { + let previous = previous + .map(|(key, activation_block_num)| scheduled_local_key(key, activation_block_num)) + .transpose()?; + let current = scheduled_local_key(current.0, current.1)?; + let next = next + .map(|(key, activation_block_num)| scheduled_local_key(key, activation_block_num)) + .transpose()?; + + if let Some(previous) = &previous { + anyhow::ensure!( + previous.activation_block_num < current.activation_block_num, + "previous key activation must be before current key activation" + ); + anyhow::ensure!( + previous.key.info.key_id != current.key.info.key_id, + "previous and current keys must have distinct ids" + ); + } + if let Some(next) = &next { + anyhow::ensure!( + current.activation_block_num < next.activation_block_num, + "next key activation must be after current key activation" + ); + anyhow::ensure!( + current.key.info.key_id != next.key.info.key_id, + "current and next keys must have distinct ids" + ); + anyhow::ensure!( + previous + .as_ref() + .is_none_or(|previous| previous.key.info.key_id != next.key.info.key_id), + "previous and next keys must have distinct ids" + ); + } + + Ok(Self { previous, current, next }) + } + + /// Adds a manually chosen replacement key at a future epoch boundary. + pub fn with_scheduled_rotation( + mut self, + secret_key: KeyExchangeKey, + activation_block_num: BlockNumber, + ) -> anyhow::Result { + ensure_epoch_boundary(activation_block_num)?; + anyhow::ensure!( + activation_block_num > self.current.activation_block_num, + "next key activation must be after current key activation" + ); + let next = LocalEncryptionKey::new(secret_key); + anyhow::ensure!( + next.info.key_id != self.current.key.info.key_id, + "current and next keys must have distinct ids" + ); + anyhow::ensure!( + self.previous + .as_ref() + .is_none_or(|previous| previous.key.info.key_id != next.info.key_id), + "previous and next keys must have distinct ids" + ); + self.next = Some(ScheduledLocalEncryptionKey { key: next, activation_block_num }); + Ok(self) } - /// Returns the public key of the shared encryption key. + /// Returns the public key of the key currently configured as current. pub fn public_key(&self) -> EncryptionPublicKey { - self.secret_key.public_key() + self.current.key.secret_key.public_key() } - /// Returns the opaque identifier of the current encryption key: the first 4 bytes of the public - /// key commitment. + /// Returns the opaque identifier of the key currently configured as current. pub fn key_id(&self) -> Vec { - self.public_key().to_commitment().to_bytes()[..4].to_vec() + self.current.key.info.key_id.clone() } /// Returns the sealing key that clients use to encrypt messages to the validator set. #[cfg(test)] - pub fn sealing_key(&self) -> SealingKey { - SealingKey::X25519XChaCha20Poly1305(self.public_key()) + pub(crate) fn sealing_key(&self) -> SealingKey { + SealingKey::X25519XChaCha20Poly1305(self.current.key.secret_key.public_key()) + } + + #[cfg(test)] + fn previous_sealing_key(&self) -> Option { + self.previous.as_ref().map(|previous| { + SealingKey::X25519XChaCha20Poly1305(previous.key.secret_key.public_key()) + }) + } + + #[cfg(test)] + pub(crate) fn next_sealing_key(&self) -> Option { + self.next + .as_ref() + .map(|next| SealingKey::X25519XChaCha20Poly1305(next.key.secret_key.public_key())) + } + + fn unseal( + key: &LocalEncryptionKey, + message: SealedMessage, + associated_data: &[u8], + ) -> Result, TransactionInputDecryptionError> { + use anyhow::Context; + + UnsealingKey::X25519XChaCha20Poly1305(key.secret_key.clone()) + .unseal_bytes_with_associated_data(message, associated_data) + .context("failed to unseal with the selected transaction encryption key") + .map_err(TransactionInputDecryptionError::DecryptionFailed) + } + + fn key_for_decryption( + &self, + key_id: &[u8], + chain_tip: BlockNumber, + ) -> Result<&LocalEncryptionKey, TransactionInputDecryptionError> { + if let Some(previous) = &self.previous + && key_id == previous.key.info.key_id + { + if chain_tip < previous.activation_block_num { + return Err(TransactionInputDecryptionError::PrematureKey { + key_id: hex::encode(key_id), + activation: previous.activation_block_num, + }); + } + if let Some(grace_expiry) = self + .current + .activation_block_num + .block_epoch() + .checked_add(1) + .map(BlockNumber::from_epoch) + && chain_tip >= grace_expiry + { + return Err(TransactionInputDecryptionError::ExpiredKey { + key_id: hex::encode(key_id), + expired_at: grace_expiry, + }); + } + return Ok(&previous.key); + } + + if key_id == self.current.key.info.key_id { + if chain_tip < self.current.activation_block_num { + return Err(TransactionInputDecryptionError::PrematureKey { + key_id: hex::encode(key_id), + activation: self.current.activation_block_num, + }); + } + + if let Some(next) = &self.next + && chain_tip >= next.activation_block_num + && let Some(grace_expiry) = next + .activation_block_num + .block_epoch() + .checked_add(1) + .map(BlockNumber::from_epoch) + && chain_tip >= grace_expiry + { + return Err(TransactionInputDecryptionError::ExpiredKey { + key_id: hex::encode(key_id), + expired_at: grace_expiry, + }); + } + + return Ok(&self.current.key); + } + + if let Some(next) = &self.next + && key_id == next.key.info.key_id + { + if chain_tip < next.activation_block_num { + return Err(TransactionInputDecryptionError::PrematureKey { + key_id: hex::encode(key_id), + activation: next.activation_block_num, + }); + } + return Ok(&next.key); + } + + Err(TransactionInputDecryptionError::UnknownKey { key_id: hex::encode(key_id) }) } } #[tonic::async_trait] impl TransactionInputDecrypter for LocalX25519TransactionInputDecrypter { - async fn encryption_key(&self) -> anyhow::Result { - Ok(TransactionEncryptionKeyInfo { - scheme: Self::SCHEME, - key_id: self.key_id(), - public_key: self.public_key().to_bytes(), - next_key: None, + async fn encryption_key_schedule( + &self, + chain_tip: BlockNumber, + ) -> anyhow::Result { + let previous_is_required = self.current.activation_block_num != BlockNumber::GENESIS + && self + .current + .activation_block_num + .block_epoch() + .checked_add(1) + .map(BlockNumber::from_epoch) + .is_none_or(|grace_expiry| chain_tip < grace_expiry); + anyhow::ensure!( + !previous_is_required || self.previous.is_some(), + "a previous key is required through the current key's activation epoch" + ); + + if chain_tip < self.current.activation_block_num { + let previous = self.previous.as_ref().ok_or_else(|| { + anyhow::anyhow!( + "current key does not activate until block {} and no previous key is configured", + self.current.activation_block_num + ) + })?; + return Ok(TransactionEncryptionKeySchedule { + current_key: previous.key.info.clone(), + current_key_activation_block_num: previous.activation_block_num, + next_key: Some(NextTransactionEncryptionKey { + key: self.current.key.info.clone(), + activation_block_num: self.current.activation_block_num, + }), + }); + } + + if let Some(next) = &self.next + && chain_tip >= next.activation_block_num + { + return Ok(TransactionEncryptionKeySchedule { + current_key: next.key.info.clone(), + current_key_activation_block_num: next.activation_block_num, + next_key: None, + }); + } + + Ok(TransactionEncryptionKeySchedule { + current_key: self.current.key.info.clone(), + current_key_activation_block_num: self.current.activation_block_num, + next_key: self.next.as_ref().map(|next| NextTransactionEncryptionKey { + key: next.key.info.clone(), + activation_block_num: next.activation_block_num, + }), }) } async fn decrypt_transaction_inputs( &self, + key_id: &[u8], + chain_tip: BlockNumber, ciphertext: &[u8], associated_data: &[u8], - ) -> anyhow::Result> { + ) -> Result, TransactionInputDecryptionError> { use anyhow::Context; let message = SealedMessage::read_from_bytes(ciphertext) - .context("failed to deserialize the sealed message")?; - - 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())) + .context("failed to deserialize the sealed message") + .map_err(TransactionInputDecryptionError::InvalidCiphertext)?; + let key = self.key_for_decryption(key_id, chain_tip)?; + Self::unseal(key, message, associated_data) } } +/// Returns the opaque identifier of an encryption key: the first 4 bytes of its public key +/// commitment. +fn key_id_of(public_key: &EncryptionPublicKey) -> Vec { + public_key.to_commitment().to_bytes()[..4].to_vec() +} + +fn scheduled_local_key( + secret_key: KeyExchangeKey, + activation_block_num: BlockNumber, +) -> anyhow::Result { + ensure_epoch_boundary(activation_block_num)?; + Ok(ScheduledLocalEncryptionKey { + key: LocalEncryptionKey::new(secret_key), + activation_block_num, + }) +} + +fn ensure_epoch_boundary(block_num: BlockNumber) -> anyhow::Result<()> { + anyhow::ensure!( + BlockNumber::from_epoch(block_num.block_epoch()) == block_num, + "key activation block must be an epoch boundary" + ); + Ok(()) +} + // TESTS // ================================================================================================= #[cfg(test)] mod tests { - use miden_protocol::utils::serde::Deserializable; use rand::rng; use super::*; - fn decrypter_from(secret: &[u8; 32]) -> LocalX25519TransactionInputDecrypter { - LocalX25519TransactionInputDecrypter::new(KeyExchangeKey::read_from_bytes(secret).unwrap()) + fn key(seed: u8) -> KeyExchangeKey { + KeyExchangeKey::read_from_bytes(&[seed; 32]).unwrap() + } + + fn decrypter(seed: u8) -> LocalX25519TransactionInputDecrypter { + LocalX25519TransactionInputDecrypter::new(key(seed)) + } + + fn scheduled_decrypter() -> LocalX25519TransactionInputDecrypter { + decrypter(7) + .with_scheduled_rotation(key(8), BlockNumber::from_epoch(1)) + .unwrap() + } + + fn repeated_rotation_decrypter() -> LocalX25519TransactionInputDecrypter { + LocalX25519TransactionInputDecrypter::from_schedule( + Some((key(7), BlockNumber::GENESIS)), + (key(8), BlockNumber::from_epoch(1)), + Some((key(9), BlockNumber::from_epoch(2))), + ) + .unwrap() + } + + fn seal(sealing_key: &SealingKey, plaintext: &[u8], associated_data: &[u8]) -> Vec { + sealing_key + .seal_bytes_with_associated_data(&mut rng(), plaintext, associated_data) + .unwrap() + .to_bytes() + } + + fn repeated_rotation_ciphertexts( + decrypter: &LocalX25519TransactionInputDecrypter, + associated_data: &[u8], + ) -> (Vec, Vec, Vec) { + ( + seal(&decrypter.previous_sealing_key().unwrap(), b"previous", associated_data), + seal(&decrypter.sealing_key(), b"current", associated_data), + seal(&decrypter.next_sealing_key().unwrap(), b"next", associated_data), + ) } - /// Loading the same shared secret must yield the same key metadata and attestation commitment - /// on every validator instance. + async fn assert_decrypts( + decrypter: &LocalX25519TransactionInputDecrypter, + key_id: &[u8], + chain_tip: BlockNumber, + ciphertext: &[u8], + associated_data: &[u8], + expected: &[u8], + ) { + assert_eq!( + decrypter + .decrypt_transaction_inputs(key_id, chain_tip, ciphertext, associated_data,) + .await + .unwrap(), + expected + ); + } + + async fn assert_premature( + decrypter: &LocalX25519TransactionInputDecrypter, + key_id: &[u8], + chain_tip: BlockNumber, + ciphertext: &[u8], + associated_data: &[u8], + ) { + assert!(matches!( + decrypter + .decrypt_transaction_inputs(key_id, chain_tip, ciphertext, associated_data) + .await, + Err(TransactionInputDecryptionError::PrematureKey { .. }) + )); + } + + async fn assert_expired( + decrypter: &LocalX25519TransactionInputDecrypter, + key_id: &[u8], + chain_tip: BlockNumber, + ciphertext: &[u8], + associated_data: &[u8], + ) { + assert!(matches!( + decrypter + .decrypt_transaction_inputs(key_id, chain_tip, ciphertext, associated_data) + .await, + Err(TransactionInputDecryptionError::ExpiredKey { .. }) + )); + } + + /// Loading the same shared secret must yield the same schedule on every validator instance, + /// regardless of where in an epoch the chain tip sits. #[tokio::test] - async fn same_secret_yields_same_public_material() { - let genesis = Word::try_from([1u64, 2, 3, 4]).unwrap(); - let info_a = decrypter_from(&[7u8; 32]).encryption_key().await.unwrap(); - let info_b = decrypter_from(&[7u8; 32]).encryption_key().await.unwrap(); + async fn same_key_yields_same_public_material() { + let a = decrypter(7).encryption_key_schedule(BlockNumber::from(1)).await.unwrap(); + let b = decrypter(7).encryption_key_schedule(BlockNumber::from_epoch(4)).await.unwrap(); - assert_eq!(info_a, info_b); - assert_eq!(info_a.attestation_commitment(genesis), info_b.attestation_commitment(genesis)); + assert_eq!(a, b); + assert_eq!(a.current_key.key_id.len(), 4); } /// Different secrets must yield different public keys and key ids. #[tokio::test] - async fn different_secrets_yield_different_public_material() { - let genesis = Word::try_from([1u64, 2, 3, 4]).unwrap(); - let info_a = decrypter_from(&[7u8; 32]).encryption_key().await.unwrap(); - let info_b = decrypter_from(&[8u8; 32]).encryption_key().await.unwrap(); + async fn different_keys_yield_different_provider_owned_ids() { + let a = decrypter(7).encryption_key_schedule(BlockNumber::GENESIS).await.unwrap(); + let b = decrypter(8).encryption_key_schedule(BlockNumber::GENESIS).await.unwrap(); - assert_eq!(info_a.scheme, info_b.scheme); - assert_ne!(info_a.public_key, info_b.public_key); - assert_ne!(info_a.key_id, info_b.key_id); - assert_ne!(info_a.attestation_commitment(genesis), info_b.attestation_commitment(genesis)); + assert_eq!(a.current_key.scheme, b.current_key.scheme); + assert_ne!(a.current_key.public_key, b.current_key.public_key); + assert_ne!(a.current_key.key_id, b.current_key.key_id); } - /// A message sealed against the decrypter's sealing key must decrypt to the original plaintext, - /// and decryption must reject a mismatched associated data or a mismatched key. #[tokio::test] - async fn seal_decrypt_roundtrip() { - let mut rng = rng(); - let decrypter = decrypter_from(&[7u8; 32]); - let plaintext = b"transaction inputs"; - let associated_data = b"scheme|key_id|chain|tx"; + async fn scheduled_key_activates_only_at_declared_epoch_boundary() { + let decrypter = scheduled_decrypter(); + let before = decrypter + .encryption_key_schedule(BlockNumber::from((1 << 16) - 1)) + .await + .unwrap(); + let at = decrypter.encryption_key_schedule(BlockNumber::from_epoch(1)).await.unwrap(); + + assert!(before.next_key.is_some()); + assert_eq!(before.next_key.unwrap().activation_block_num, BlockNumber::from_epoch(1)); + assert_eq!(at.current_key.key_id, decrypter.next.as_ref().unwrap().key.info.key_id); + assert_eq!(at.current_key_activation_block_num, BlockNumber::from_epoch(1)); + assert!(at.next_key.is_none()); + } - let sealed = decrypter - .sealing_key() - .seal_bytes_with_associated_data(&mut rng, plaintext, associated_data) - .unwrap() - .to_bytes(); - let opened = decrypter.decrypt_transaction_inputs(&sealed, associated_data).await.unwrap(); - assert_eq!(opened.as_slice(), plaintext); + #[test] + fn scheduled_rotation_rejects_non_boundary_activation() { + let result = decrypter(7).with_scheduled_rotation(key(8), BlockNumber::from((1 << 16) + 1)); + assert!(result.is_err()); + } - // Mismatched associated data must fail authentication. - assert!( + #[tokio::test] + async fn decrypts_with_current_key_id() { + let decrypter = decrypter(7); + let schedule = decrypter.encryption_key_schedule(BlockNumber::from(42)).await.unwrap(); + let associated_data = b"scheme|key_id|chain|tx"; + let ciphertext = seal(&decrypter.sealing_key(), b"transaction inputs", associated_data); + + let plaintext = decrypter + .decrypt_transaction_inputs( + &schedule.current_key.key_id, + BlockNumber::from(42), + &ciphertext, + associated_data, + ) + .await + .unwrap(); + assert_eq!(plaintext, b"transaction inputs"); + } + + #[tokio::test] + async fn enforces_premature_grace_expired_and_unknown_key_ids() { + let decrypter = scheduled_decrypter(); + let before = decrypter.encryption_key_schedule(BlockNumber::GENESIS).await.unwrap(); + let current_id = before.current_key.key_id; + let next_id = before.next_key.unwrap().key.key_id; + let associated_data = b"associated"; + let current_ciphertext = seal(&decrypter.sealing_key(), b"current", associated_data); + let next_ciphertext = + seal(&decrypter.next_sealing_key().unwrap(), b"next", associated_data); + + assert!(matches!( decrypter - .decrypt_transaction_inputs(&sealed, b"wrong associated data") + .decrypt_transaction_inputs( + &next_id, + BlockNumber::from((1 << 16) - 1), + &next_ciphertext, + associated_data, + ) + .await, + Err(TransactionInputDecryptionError::PrematureKey { .. }) + )); + + let activation = BlockNumber::from_epoch(1); + assert_eq!( + decrypter + .decrypt_transaction_inputs(&next_id, activation, &next_ciphertext, associated_data) .await - .is_err() + .unwrap(), + b"next" ); + assert_eq!( + decrypter + .decrypt_transaction_inputs( + ¤t_id, + activation, + ¤t_ciphertext, + associated_data, + ) + .await + .unwrap(), + b"current" + ); + + assert!(matches!( + decrypter + .decrypt_transaction_inputs( + ¤t_id, + BlockNumber::from_epoch(2), + ¤t_ciphertext, + associated_data, + ) + .await, + Err(TransactionInputDecryptionError::ExpiredKey { .. }) + )); + assert!(matches!( + decrypter + .decrypt_transaction_inputs( + b"not-a-provider-key", + activation, + &next_ciphertext, + associated_data, + ) + .await, + Err(TransactionInputDecryptionError::UnknownKey { .. }) + )); + } + + #[tokio::test] + async fn two_manual_rotations_enforce_activation_and_grace() { + let decrypter = repeated_rotation_decrypter(); + let previous_id = decrypter.previous.as_ref().unwrap().key.info.key_id.clone(); + let current_id = decrypter.current.key.info.key_id.clone(); + let next_id = decrypter.next.as_ref().unwrap().key.info.key_id.clone(); + let associated_data = b"associated"; + let (previous_ciphertext, current_ciphertext, next_ciphertext) = + repeated_rotation_ciphertexts(&decrypter, associated_data); + + let before_first = BlockNumber::from((1 << 16) - 1); + let before_schedule = decrypter.encryption_key_schedule(before_first).await.unwrap(); + assert_eq!(before_schedule.current_key.key_id, previous_id); + assert_eq!(before_schedule.next_key.unwrap().key.key_id, current_id); + assert_premature( + &decrypter, + ¤t_id, + before_first, + ¤t_ciphertext, + associated_data, + ) + .await; + assert_premature(&decrypter, &next_id, before_first, &next_ciphertext, associated_data) + .await; + + let first_activation = BlockNumber::from_epoch(1); + let first_schedule = decrypter.encryption_key_schedule(first_activation).await.unwrap(); + assert_eq!(first_schedule.current_key.key_id, current_id); + assert_eq!(first_schedule.next_key.unwrap().key.key_id, next_id); + assert_decrypts( + &decrypter, + &previous_id, + first_activation, + &previous_ciphertext, + associated_data, + b"previous", + ) + .await; + + let second_activation = BlockNumber::from_epoch(2); + let second_schedule = decrypter.encryption_key_schedule(second_activation).await.unwrap(); + assert_eq!(second_schedule.current_key.key_id, next_id); + assert!(second_schedule.next_key.is_none()); + assert_decrypts( + &decrypter, + ¤t_id, + second_activation, + ¤t_ciphertext, + associated_data, + b"current", + ) + .await; + assert_expired( + &decrypter, + &previous_id, + second_activation, + &previous_ciphertext, + associated_data, + ) + .await; + assert_decrypts( + &decrypter, + &next_id, + second_activation, + &next_ciphertext, + associated_data, + b"next", + ) + .await; + + assert_expired( + &decrypter, + ¤t_id, + BlockNumber::from_epoch(3), + ¤t_ciphertext, + associated_data, + ) + .await; + assert!(matches!( + decrypter + .decrypt_transaction_inputs( + b"unknown", + second_activation, + &next_ciphertext, + associated_data, + ) + .await, + Err(TransactionInputDecryptionError::UnknownKey { .. }) + )); + } - // A different shared secret must fail to decrypt. - let other = decrypter_from(&[8u8; 32]); - assert!(other.decrypt_transaction_inputs(&sealed, associated_data).await.is_err()); + #[tokio::test] + async fn previous_key_can_be_dropped_after_grace_expiry() { + let decrypter = LocalX25519TransactionInputDecrypter::from_schedule( + None, + (key(8), BlockNumber::from_epoch(1)), + None, + ) + .unwrap(); + + assert!(decrypter.encryption_key_schedule(BlockNumber::from_epoch(1)).await.is_err()); + + let schedule = decrypter.encryption_key_schedule(BlockNumber::from_epoch(2)).await.unwrap(); + assert_eq!(schedule.current_key.key_id, decrypter.current.key.info.key_id); + } - // Garbage ciphertext must fail to deserialize. - assert!( + #[tokio::test] + async fn rejects_wrong_associated_data_and_malformed_ciphertext() { + let decrypter = decrypter(7); + let key_id = decrypter.current.key.info.key_id.clone(); + let ciphertext = seal(&decrypter.sealing_key(), b"inputs", b"correct"); + + assert!(matches!( + decrypter + .decrypt_transaction_inputs(&key_id, BlockNumber::GENESIS, &ciphertext, b"wrong",) + .await, + Err(TransactionInputDecryptionError::DecryptionFailed(_)) + )); + assert!(matches!( decrypter - .decrypt_transaction_inputs(b"not a sealed message", associated_data) + .decrypt_transaction_inputs( + &key_id, + BlockNumber::GENESIS, + b"not a sealed message", + b"correct", + ) + .await, + Err(TransactionInputDecryptionError::InvalidCiphertext(_)) + )); + } + + struct OperationOnlyProvider { + schedule: TransactionEncryptionKeySchedule, + } + + #[tonic::async_trait] + impl TransactionInputDecrypter for OperationOnlyProvider { + async fn encryption_key_schedule( + &self, + _chain_tip: BlockNumber, + ) -> anyhow::Result { + Ok(self.schedule.clone()) + } + + async fn decrypt_transaction_inputs( + &self, + _key_id: &[u8], + _chain_tip: BlockNumber, + ciphertext: &[u8], + _associated_data: &[u8], + ) -> Result, TransactionInputDecryptionError> { + Ok(ciphertext.to_vec()) + } + } + + #[tokio::test] + async fn provider_contract_does_not_require_secret_export() { + let schedule = decrypter(7).encryption_key_schedule(BlockNumber::GENESIS).await.unwrap(); + let provider = OperationOnlyProvider { schedule }; + + assert_eq!( + provider + .decrypt_transaction_inputs( + b"opaque", + BlockNumber::GENESIS, + b"plaintext from hardware", + b"associated", + ) .await - .is_err() + .unwrap(), + b"plaintext from hardware" ); } } diff --git a/crates/proto/src/domain/encryption.rs b/crates/proto/src/domain/encryption.rs index b75305aaa3..05f007b816 100644 --- a/crates/proto/src/domain/encryption.rs +++ b/crates/proto/src/domain/encryption.rs @@ -1,11 +1,18 @@ -//! Sealing of transaction inputs against the validator set's shared encryption key. +//! Sealing of transaction inputs against the validator set's shared encryption key, and +//! verification of the key schedule a validator serves. //! //! 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. +//! +//! It is also the single definition of the attestation transcript. A validator serves a complete +//! key schedule (the key in effect plus an optionally scheduled replacement) under one signature, +//! so a relaying node can neither strip a scheduled rotation nor replay a schedule from an earlier +//! epoch. use miden_protocol::Word; +use miden_protocol::block::BlockNumber; use miden_protocol::crypto::dsa::ecdsa_k256_keccak::{ PublicKey as ValidatorPublicKey, Signature as ValidatorSignature, @@ -23,8 +30,12 @@ use crate::generated as proto; /// 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"; +/// Domain tag prefixed to the validator-signed key schedule payload. +/// +/// The `V2` transcript covers a whole schedule and its attestation epoch, where `V1` covered a +/// single key. The tag distinguishes the two so a `V1` signature can never be replayed as a `V2` +/// one. +pub const ATTESTATION_DOMAIN: &[u8] = b"MIDEN_TX_ENCRYPTION_KEY_SCHEDULE_ATTESTATION_V2"; /// Upper bound on the length of an encryption key identifier. /// @@ -71,66 +82,139 @@ impl TryFrom for TransactionEncryptionScheme { } } -/// Public metadata for a scheduled transaction encryption key. +/// Public metadata for one provider-owned transaction encryption key. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct NextEncryptionKeyInfo { - /// Encryption scheme for the scheduled key. +pub struct TransactionEncryptionKeyInfo { + /// Encryption scheme for this key. pub scheme: TransactionEncryptionScheme, - /// Opaque identifier of the scheduled key. + /// Opaque identifier assigned by the key provider. 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. +/// A key scheduled to replace the current one at an epoch boundary. #[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, +pub struct NextTransactionEncryptionKey { + /// The key that becomes current at `activation_block_num`. + pub key: TransactionEncryptionKeyInfo, + /// Epoch boundary at which the key takes effect. + pub activation_block_num: BlockNumber, } -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(), - ) +/// The complete transaction encryption key schedule served by a validator. +/// +/// Both keys are covered by one attestation, so the schedule is verified and validated as a unit. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TransactionEncryptionKeySchedule { + /// The key currently in effect. + pub current_key: TransactionEncryptionKeyInfo, + /// Epoch boundary at which `current_key` took effect. + pub current_key_activation_block_num: BlockNumber, + /// Scheduled replacement key, when a rotation has been scheduled. + pub next_key: Option, +} + +impl TransactionEncryptionKeySchedule { + /// Returns the commitment a validator signs to attest this schedule for one network and epoch. + /// + /// The layout is `ATTESTATION_DOMAIN || genesis_commitment || attestation_epoch || + /// current_activation || current_key || next_key_present`, followed by `next_activation || + /// next_key` when a rotation is scheduled. Each key contributes `scheme || len(key_id) || + /// key_id || len(public_key) || public_key`, with the scheme, the epoch, the block numbers and + /// the length prefixes encoded as 4 bytes little-endian. + /// + /// Each binding serves a purpose: + /// - `genesis_commitment` ties the schedule to one network, so a schedule captured on one + /// network cannot replay onto another that shares the same insecure development key. + /// - `attestation_epoch` ties it to one epoch, so a schedule attested before a rotation cannot + /// be replayed after it to keep clients sealing against a retired key. + /// - `next_key_present` is an explicit one-byte discriminant, so a relaying node can neither + /// strip a scheduled rotation nor inject one. + pub fn attestation_commitment(&self, genesis_commitment: Word, attestation_epoch: u16) -> Word { + let mut payload = Vec::new(); + payload.extend_from_slice(ATTESTATION_DOMAIN); + payload.extend_from_slice(&genesis_commitment.to_bytes()); + payload.extend_from_slice(&u32::from(attestation_epoch).to_le_bytes()); + payload.extend_from_slice(&self.current_key_activation_block_num.as_u32().to_le_bytes()); + encode_key(&mut payload, &self.current_key); + + match &self.next_key { + None => payload.push(0), + Some(next) => { + payload.push(1); + payload.extend_from_slice(&next.activation_block_num.as_u32().to_le_bytes()); + encode_key(&mut payload, &next.key); + }, + } + + miden_protocol::Hasher::hash(&payload) + } + + /// Validates the schedule's activation rules against a trusted chain tip. + /// + /// Keys activate only at epoch boundaries, the current key must already be active, and a + /// scheduled key must still be in the future and distinct from the current one. + pub fn validate_at( + &self, + trusted_chain_tip: BlockNumber, + ) -> Result<(), TransactionEncryptionKeyError> { + validate_epoch_boundary(self.current_key_activation_block_num, "current key activation")?; + if self.current_key_activation_block_num > trusted_chain_tip { + return Err(TransactionEncryptionKeyError::PrematureCurrentKey { + activation: self.current_key_activation_block_num, + trusted_chain_tip, + }); + } + + if let Some(next) = &self.next_key { + validate_epoch_boundary(next.activation_block_num, "next key activation")?; + if next.activation_block_num <= trusted_chain_tip { + return Err(TransactionEncryptionKeyError::NextKeyAlreadyActive { + activation: next.activation_block_num, + trusted_chain_tip, + }); + } + if next.activation_block_num <= self.current_key_activation_block_num { + return Err(TransactionEncryptionKeyError::InvalidActivationOrder); + } + if next.key.key_id == self.current_key.key_id { + return Err(TransactionEncryptionKeyError::DuplicateKeyId); + } + } + + Ok(()) } } -/// Trusted chain state used to verify a served transaction encryption key. +/// Trusted chain state used to verify a served transaction encryption key schedule. +/// +/// The chain tip must come from a trusted source, because it is what bounds the attestation to the +/// current epoch and what decides whether a scheduled key is still in the future. #[derive(Debug, Clone, Copy)] pub struct TrustedTransactionEncryptionState<'a> { genesis_commitment: Word, + chain_tip: BlockNumber, validator_signing_keys: &'a [ValidatorPublicKey], } impl<'a> TrustedTransactionEncryptionState<'a> { - /// Creates trusted state from a genesis commitment and its validator signing keys. + /// Creates trusted state from a genesis commitment, a trusted chain tip and the validator + /// signing keys committed by the chain. pub const fn new( genesis_commitment: Word, + chain_tip: BlockNumber, validator_signing_keys: &'a [ValidatorPublicKey], ) -> Self { Self { genesis_commitment, + chain_tip, validator_signing_keys, } } } -/// A transaction encryption key whose attestation matches trusted chain state. +/// A single transaction encryption key whose attestation matched trusted chain state. #[derive(Debug, Clone)] pub struct VerifiedTransactionEncryptionKey { info: TransactionEncryptionKeyInfo, @@ -155,6 +239,33 @@ impl VerifiedTransactionEncryptionKey { } } +/// A key schedule whose attestation matched trusted chain state. +#[derive(Debug, Clone)] +pub struct VerifiedTransactionEncryptionSchedule { + schedule: TransactionEncryptionKeySchedule, + current_key: VerifiedTransactionEncryptionKey, +} + +impl VerifiedTransactionEncryptionSchedule { + /// Returns the verified schedule. + pub const fn schedule(&self) -> &TransactionEncryptionKeySchedule { + &self.schedule + } + + /// Returns the key currently in effect. + pub const fn current_key(&self) -> &VerifiedTransactionEncryptionKey { + &self.current_key + } + + /// Consumes the schedule, returning the key currently in effect. + /// + /// This is what a sealing client needs: it seals against the current key and refetches the + /// schedule once the validator reports an unknown key ID. + pub fn into_current_key(self) -> VerifiedTransactionEncryptionKey { + self.current_key + } +} + // ASSOCIATED DATA // ================================================================================================ @@ -212,7 +323,7 @@ pub fn transaction_inputs_associated_data( // ERRORS // ================================================================================================ -/// Failure to decode or verify a served transaction encryption key. +/// Failure to decode or verify a served transaction encryption key schedule. #[derive(Debug, thiserror::Error)] pub enum TransactionEncryptionKeyError { #[error("encryption key scheme is unspecified")] @@ -229,13 +340,47 @@ pub enum TransactionEncryptionKeyError { #[source] source: miden_protocol::utils::serde::DeserializationError, }, + #[error("the schedule is missing its current key")] + MissingCurrentKey, + #[error("the schedule contains a next-key wrapper without a key")] + MissingNextKey, + #[error("the attestation epoch does not fit in the chain epoch type")] + InvalidAttestationEpoch, + #[error( + "schedule attestation epoch {attestation_epoch} does not match trusted epoch {trusted_epoch}" + )] + StaleAttestation { + attestation_epoch: u16, + trusted_epoch: u16, + }, + #[error("{name} block {block_num} is not an epoch boundary")] + NotEpochBoundary { + name: &'static str, + block_num: BlockNumber, + }, + #[error("current key activates at {activation}, after trusted chain tip {trusted_chain_tip}")] + PrematureCurrentKey { + activation: BlockNumber, + trusted_chain_tip: BlockNumber, + }, + #[error( + "next key activated at {activation}, no later than trusted chain tip {trusted_chain_tip}" + )] + NextKeyAlreadyActive { + activation: BlockNumber, + trusted_chain_tip: BlockNumber, + }, + #[error("next key activation must be after current key activation")] + InvalidActivationOrder, + #[error("current and next keys must have distinct provider-owned ids")] + DuplicateKeyId, #[error("trusted validator signing keys are empty")] NoTrustedValidatorKeys, - #[error("transaction encryption key has no validator attestations")] + #[error("transaction encryption key schedule has no validator attestations")] NoAttestations, - #[error("transaction encryption key has no attestation from a trusted validator")] + #[error("transaction encryption key schedule has no attestation from a trusted validator")] NoTrustedAttestation, - #[error("trusted validator attestation does not cover the transaction encryption key")] + #[error("trusted validator attestation does not cover the transaction encryption key schedule")] InvalidAttestation, } @@ -249,23 +394,66 @@ pub enum TransactionInputSealError { // ATTESTATION // ================================================================================================ -/// Verifies a served transaction encryption key against trusted chain state. -pub fn verify_transaction_encryption_key( - key: proto::transaction::TransactionEncryptionKey, +/// Verifies a served transaction encryption key schedule against trusted chain state. +/// +/// The schedule is decoded and its activation rules validated before any signature is checked, so +/// a malformed or premature schedule is rejected on its own terms rather than on a signature +/// mismatch. +pub fn verify_transaction_encryption_key_schedule( + response: &proto::transaction::TransactionEncryptionKeyResponse, trusted: TrustedTransactionEncryptionState<'_>, -) -> Result { +) -> Result { if trusted.validator_signing_keys.is_empty() { return Err(TransactionEncryptionKeyError::NoTrustedValidatorKeys); } - if key.attestations.is_empty() { + if response.attestations.is_empty() { return Err(TransactionEncryptionKeyError::NoAttestations); } - let (info, public_key) = decode_key_info(&key)?; - let commitment = info.attestation_commitment(trusted.genesis_commitment); + let attestation_epoch = u16::try_from(response.attestation_epoch) + .map_err(|_| TransactionEncryptionKeyError::InvalidAttestationEpoch)?; + let trusted_epoch = trusted.chain_tip.block_epoch(); + if attestation_epoch != trusted_epoch { + return Err(TransactionEncryptionKeyError::StaleAttestation { + attestation_epoch, + trusted_epoch, + }); + } + + let (current_key, current_public_key) = response + .current_key + .as_ref() + .ok_or(TransactionEncryptionKeyError::MissingCurrentKey) + .and_then(|key| decode_key(key, "current encryption key"))?; + let next_key = response + .next_key + .as_ref() + .map(|next| { + let (key, _) = next + .key + .as_ref() + .ok_or(TransactionEncryptionKeyError::MissingNextKey) + .and_then(|key| decode_key(key, "next encryption key"))?; + Ok(NextTransactionEncryptionKey { + key, + activation_block_num: BlockNumber::from(next.activation_block_num), + }) + }) + .transpose()?; + + let schedule = TransactionEncryptionKeySchedule { + current_key, + current_key_activation_block_num: BlockNumber::from( + response.current_key_activation_block_num, + ), + next_key, + }; + schedule.validate_at(trusted.chain_tip)?; + + let commitment = schedule.attestation_commitment(trusted.genesis_commitment, attestation_epoch); let mut found_trusted_signer = false; - for attestation in key.attestations { + for attestation in &response.attestations { let Ok(validator_public_key) = ValidatorPublicKey::read_from_bytes(&attestation.validator_public_key) else { @@ -281,11 +469,12 @@ pub fn verify_transaction_encryption_key( continue; }; if signature.verify(commitment, &validator_public_key) { - return Ok(VerifiedTransactionEncryptionKey { - info, - public_key, + let current_key = VerifiedTransactionEncryptionKey { + info: schedule.current_key.clone(), + public_key: current_public_key, genesis_commitment: trusted.genesis_commitment, - }); + }; + return Ok(VerifiedTransactionEncryptionSchedule { schedule, current_key }); } } @@ -296,50 +485,31 @@ pub fn verify_transaction_encryption_key( } } -/// 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, - } - })?; +/// Appends one key to the attestation transcript. +fn encode_key(payload: &mut Vec, key: &TransactionEncryptionKeyInfo) { + payload.extend_from_slice(&key.scheme.as_u32().to_le_bytes()); + extend_with_length_prefixed(payload, &key.key_id, "key id"); + extend_with_length_prefixed(payload, &key.public_key, "public key"); +} - Ok(NextEncryptionKeyInfo { - scheme, - key_id: next.key_id.clone(), - public_key: next.public_key.clone(), - rotation_block_num: next.rotation_block_num, - }) - }) - .transpose()?; +/// 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); +} - Ok(( - TransactionEncryptionKeyInfo { - scheme, - key_id: key.key_id.clone(), - public_key: key.public_key.clone(), - next_key, - }, - public_key, - )) +/// Rejects a block number which is not the first block of an epoch. +fn validate_epoch_boundary( + block_num: BlockNumber, + name: &'static str, +) -> Result<(), TransactionEncryptionKeyError> { + if BlockNumber::from_epoch(block_num.block_epoch()) != block_num { + return Err(TransactionEncryptionKeyError::NotEpochBoundary { name, block_num }); + } + Ok(()) } /// Validates a key identifier before it is used in a transcript or allocation. @@ -356,47 +526,25 @@ fn validate_key_id( 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) -} +/// Decodes one key of the schedule, returning it alongside its decoded public key. +fn decode_key( + key: &proto::transaction::TransactionEncryptionKey, + field: &'static str, +) -> Result<(TransactionEncryptionKeyInfo, EncryptionPublicKey), TransactionEncryptionKeyError> { + let scheme = TransactionEncryptionScheme::try_from(key.scheme)?; + validate_key_id(&key.key_id, field)?; + let public_key = EncryptionPublicKey::read_from_bytes(&key.public_key).map_err(|source| { + TransactionEncryptionKeyError::InvalidEncryptionPublicKey { field, source } + })?; -/// 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); + Ok(( + TransactionEncryptionKeyInfo { + scheme, + key_id: key.key_id.clone(), + public_key: key.public_key.clone(), + }, + public_key, + )) } // SEALER @@ -473,11 +621,20 @@ mod tests { use super::*; const TEST_KEY_ID: [u8; 4] = [0xDE, 0xAD, 0xBE, 0xEF]; + const CURRENT_ACTIVATION: u32 = 0; fn genesis() -> Word { Word::from([1u32, 2, 3, 4]) } + fn chain_tip() -> BlockNumber { + BlockNumber::from(42) + } + + fn next_activation() -> BlockNumber { + BlockNumber::from_epoch(1) + } + fn tx_id(seed: u32) -> TransactionId { TransactionId::new( Word::from([seed, 0, 0, 0]), @@ -491,48 +648,230 @@ mod tests { 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]) + fn key(seed: u8) -> TransactionEncryptionKeyInfo { + TransactionEncryptionKeyInfo { + scheme: TransactionEncryptionScheme::X25519XChaCha20Poly1305, + key_id: vec![seed; 4], + public_key: KeyExchangeKey::read_from_bytes(&[seed; 32]) .unwrap() .public_key() .to_bytes(), + } + } + + /// A schedule with the test key id, optionally scheduling a rotation. + fn schedule(next: bool) -> TransactionEncryptionKeySchedule { + TransactionEncryptionKeySchedule { + current_key: TransactionEncryptionKeyInfo { key_id: TEST_KEY_ID.to_vec(), ..key(7) }, + current_key_activation_block_num: BlockNumber::from(CURRENT_ACTIVATION), + next_key: next.then(|| NextTransactionEncryptionKey { + key: key(8), + activation_block_num: next_activation(), + }), + } + } + + fn encode(key: &TransactionEncryptionKeyInfo) -> proto::transaction::TransactionEncryptionKey { + proto::transaction::TransactionEncryptionKey { + scheme: key.scheme.as_i32(), + key_id: key.key_id.clone(), + public_key: key.public_key.clone(), + } + } + + /// An unattested wire schedule, used to check that attestations cannot simply be omitted. + fn unsigned_response( + schedule: &TransactionEncryptionKeySchedule, + attestation_epoch: u16, + ) -> proto::transaction::TransactionEncryptionKeyResponse { + proto::transaction::TransactionEncryptionKeyResponse { + current_key: Some(encode(&schedule.current_key)), + next_key: schedule.next_key.as_ref().map(|next| { + proto::transaction::NextTransactionEncryptionKey { + key: Some(encode(&next.key)), + activation_block_num: next.activation_block_num.as_u32(), + } + }), + current_key_activation_block_num: schedule.current_key_activation_block_num.as_u32(), + attestation_epoch: u32::from(attestation_epoch), attestations: Vec::new(), - next_key: None, } } - fn signed_encryption_key( - signer: &SigningKey, + fn signed_response( + schedule: &TransactionEncryptionKeySchedule, + attestation_epoch: u16, 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 { + signer: &SigningKey, + ) -> proto::transaction::TransactionEncryptionKeyResponse { + let mut response = unsigned_response(schedule, attestation_epoch); + let signature = signer + .sign(schedule.attestation_commitment(genesis_commitment, attestation_epoch)) + .to_bytes(); + response.attestations = vec![proto::transaction::ValidatorKeyAttestation { validator_public_key: signer.public_key().to_bytes(), - signature: signer.sign(info.attestation_commitment(genesis_commitment)).to_bytes(), + signature, }]; - key + response } - /// A key signed by the validator committed in trusted chain state verifies. + /// A schedule signed by the validator committed in trusted chain state verifies. #[test] - fn verifies_trusted_validator_attestation() { + fn verifies_schedule_without_rotation() { let signer = signing_key(1); let trusted_keys = [signer.public_key()]; - let key = signed_encryption_key(&signer, genesis()); + let schedule = schedule(false); + let response = signed_response(&schedule, 0, genesis(), &signer); - let verified = verify_transaction_encryption_key( - key, - TrustedTransactionEncryptionState::new(genesis(), &trusted_keys), + let verified = verify_transaction_encryption_key_schedule( + &response, + TrustedTransactionEncryptionState::new(genesis(), chain_tip(), &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()); + assert_eq!(verified.schedule(), &schedule); + assert_eq!(verified.current_key().info().key_id, TEST_KEY_ID); + assert_eq!( + verified.current_key().info().scheme, + TransactionEncryptionScheme::X25519XChaCha20Poly1305 + ); + assert_eq!(verified.current_key().genesis_commitment(), genesis()); + } + + /// A scheduled rotation is carried through verification intact. + #[test] + fn verifies_schedule_with_next_key() { + let signer = signing_key(1); + let trusted_keys = [signer.public_key()]; + let schedule = schedule(true); + let response = signed_response(&schedule, 0, genesis(), &signer); + + let verified = verify_transaction_encryption_key_schedule( + &response, + TrustedTransactionEncryptionState::new(genesis(), chain_tip(), &trusted_keys), + ) + .unwrap(); + + assert_eq!(verified.schedule(), &schedule); + } + + /// The single signature covers whether a rotation is scheduled at all, so a relaying node can + /// neither strip nor inject one. + #[test] + fn one_signature_covers_optional_next_presence() { + let signer = signing_key(1); + let trusted_keys = [signer.public_key()]; + let trusted = TrustedTransactionEncryptionState::new(genesis(), chain_tip(), &trusted_keys); + + let mut stripped = signed_response(&schedule(true), 0, genesis(), &signer); + stripped.next_key = None; + assert_matches!( + verify_transaction_encryption_key_schedule(&stripped, trusted), + Err(TransactionEncryptionKeyError::InvalidAttestation) + ); + + let mut injected = signed_response(&schedule(false), 0, genesis(), &signer); + injected.next_key = signed_response(&schedule(true), 0, genesis(), &signer).next_key; + assert_matches!( + verify_transaction_encryption_key_schedule(&injected, trusted), + Err(TransactionEncryptionKeyError::InvalidAttestation) + ); + } + + /// A schedule attested in an earlier epoch cannot be replayed to keep a retired key in use. + #[test] + fn rejects_stale_schedule_replay() { + let signer = signing_key(1); + let trusted_keys = [signer.public_key()]; + let response = signed_response(&schedule(false), 0, genesis(), &signer); + + assert_matches!( + verify_transaction_encryption_key_schedule( + &response, + TrustedTransactionEncryptionState::new( + genesis(), + BlockNumber::from_epoch(1), + &trusted_keys, + ), + ), + Err(TransactionEncryptionKeyError::StaleAttestation { + attestation_epoch: 0, + trusted_epoch: 1, + }) + ); + } + + /// A current key which has not activated yet is rejected. + #[test] + fn rejects_premature_current_key() { + let signer = signing_key(1); + let trusted_keys = [signer.public_key()]; + let mut schedule = schedule(false); + schedule.current_key_activation_block_num = BlockNumber::from_epoch(1); + let response = signed_response(&schedule, 0, genesis(), &signer); + + assert_matches!( + verify_transaction_encryption_key_schedule( + &response, + TrustedTransactionEncryptionState::new(genesis(), chain_tip(), &trusted_keys), + ), + Err(TransactionEncryptionKeyError::PrematureCurrentKey { .. }) + ); + } + + /// Keys may activate only at epoch boundaries. + #[test] + fn rejects_non_boundary_activation() { + let signer = signing_key(1); + let trusted_keys = [signer.public_key()]; + let mut schedule = schedule(true); + schedule.next_key.as_mut().unwrap().activation_block_num = + BlockNumber::from(next_activation().as_u32() + 1); + let response = signed_response(&schedule, 0, genesis(), &signer); + + assert_matches!( + verify_transaction_encryption_key_schedule( + &response, + TrustedTransactionEncryptionState::new(genesis(), chain_tip(), &trusted_keys), + ), + Err(TransactionEncryptionKeyError::NotEpochBoundary { .. }) + ); + } + + /// A scheduled key which the chain tip has already passed is rejected, as is one that reuses + /// the current key's id. + #[test] + fn rejects_invalid_next_key_schedule() { + let signer = signing_key(1); + let trusted_keys = [signer.public_key()]; + let trusted = TrustedTransactionEncryptionState::new( + genesis(), + BlockNumber::from_epoch(1), + &trusted_keys, + ); + + let mut already_active = schedule(true); + already_active.current_key_activation_block_num = BlockNumber::from_epoch(1); + assert_matches!( + verify_transaction_encryption_key_schedule( + &signed_response(&already_active, 1, genesis(), &signer), + trusted, + ), + Err(TransactionEncryptionKeyError::NextKeyAlreadyActive { .. }) + ); + + let mut duplicate_id = schedule(true); + duplicate_id.current_key_activation_block_num = BlockNumber::from_epoch(1); + duplicate_id.next_key.as_mut().unwrap().activation_block_num = BlockNumber::from_epoch(2); + duplicate_id.next_key.as_mut().unwrap().key.key_id = + duplicate_id.current_key.key_id.clone(); + assert_matches!( + verify_transaction_encryption_key_schedule( + &signed_response(&duplicate_id, 1, genesis(), &signer), + trusted, + ), + Err(TransactionEncryptionKeyError::DuplicateKeyId) + ); } /// An untrusted RPC cannot omit or rely on a malformed validator attestation. @@ -540,24 +879,27 @@ mod tests { fn rejects_missing_and_malformed_attestations() { let signer = signing_key(1); let trusted_keys = [signer.public_key()]; - let trusted = TrustedTransactionEncryptionState::new(genesis(), &trusted_keys); + let trusted = TrustedTransactionEncryptionState::new(genesis(), chain_tip(), &trusted_keys); assert_matches!( - verify_transaction_encryption_key(unsigned_encryption_key(), trusted), + verify_transaction_encryption_key_schedule( + &unsigned_response(&schedule(false), 0), + trusted, + ), Err(TransactionEncryptionKeyError::NoAttestations) ); - let mut malformed_key = signed_encryption_key(&signer, genesis()); + let mut malformed_key = signed_response(&schedule(false), 0, genesis(), &signer); malformed_key.attestations[0].validator_public_key.clear(); assert_matches!( - verify_transaction_encryption_key(malformed_key, trusted), + verify_transaction_encryption_key_schedule(&malformed_key, trusted), Err(TransactionEncryptionKeyError::NoTrustedAttestation) ); - let mut malformed_signature = signed_encryption_key(&signer, genesis()); + let mut malformed_signature = signed_response(&schedule(false), 0, genesis(), &signer); malformed_signature.attestations[0].signature.clear(); assert_matches!( - verify_transaction_encryption_key(malformed_signature, trusted), + verify_transaction_encryption_key_schedule(&malformed_signature, trusted), Err(TransactionEncryptionKeyError::InvalidAttestation) ); } @@ -567,8 +909,8 @@ mod tests { 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( + let mut response = signed_response(&schedule(false), 0, genesis(), &signer); + response.attestations.insert( 0, proto::transaction::ValidatorKeyAttestation { validator_public_key: Vec::new(), @@ -576,9 +918,9 @@ mod tests { }, ); - verify_transaction_encryption_key( - key, - TrustedTransactionEncryptionState::new(genesis(), &trusted_keys), + verify_transaction_encryption_key_schedule( + &response, + TrustedTransactionEncryptionState::new(genesis(), chain_tip(), &trusted_keys), ) .unwrap(); } @@ -591,48 +933,57 @@ mod tests { let trusted_keys = [trusted_signer.public_key()]; assert_matches!( - verify_transaction_encryption_key( - signed_encryption_key(&untrusted_signer, genesis()), - TrustedTransactionEncryptionState::new(genesis(), &trusted_keys), + verify_transaction_encryption_key_schedule( + &signed_response(&schedule(false), 0, genesis(), &untrusted_signer), + TrustedTransactionEncryptionState::new(genesis(), chain_tip(), &trusted_keys), ), Err(TransactionEncryptionKeyError::NoTrustedAttestation) ); } - /// Every served key field and the network identity are covered by the signature. + /// Verification requires trusted signing keys to check against at all. + #[test] + fn rejects_empty_trusted_validator_keys() { + let signer = signing_key(1); + + assert_matches!( + verify_transaction_encryption_key_schedule( + &signed_response(&schedule(false), 0, genesis(), &signer), + TrustedTransactionEncryptionState::new(genesis(), chain_tip(), &[]), + ), + Err(TransactionEncryptionKeyError::NoTrustedValidatorKeys) + ); + } + + /// Every attested schedule 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()); + let trusted = TrustedTransactionEncryptionState::new(genesis(), chain_tip(), &trusted_keys); + let response = signed_response(&schedule(false), 0, genesis(), &signer); + + let mut changed_key_id = response.clone(); + changed_key_id.current_key.as_mut().unwrap().key_id[0] ^= 1; + let mut changed_public_key = response.clone(); + changed_public_key.current_key.as_mut().unwrap().public_key = + KeyExchangeKey::read_from_bytes(&[9u8; 32]).unwrap().public_key().to_bytes(); + + for changed in [changed_key_id, changed_public_key] { + assert_matches!( + verify_transaction_encryption_key_schedule(&changed, trusted), + Err(TransactionEncryptionKeyError::InvalidAttestation) + ); } assert_matches!( - verify_transaction_encryption_key( - key, - TrustedTransactionEncryptionState::new(Word::from([9u32, 9, 9, 9]), &trusted_keys), + verify_transaction_encryption_key_schedule( + &response, + TrustedTransactionEncryptionState::new( + Word::from([9u32, 9, 9, 9]), + chain_tip(), + &trusted_keys, + ), ), Err(TransactionEncryptionKeyError::InvalidAttestation) ); @@ -643,31 +994,81 @@ mod tests { fn rejects_invalid_key_metadata() { let signer = signing_key(1); let trusted_keys = [signer.public_key()]; - let trusted = TrustedTransactionEncryptionState::new(genesis(), &trusted_keys); + let trusted = TrustedTransactionEncryptionState::new(genesis(), chain_tip(), &trusted_keys); - let mut empty_key_id = signed_encryption_key(&signer, genesis()); - empty_key_id.key_id.clear(); + let mut unspecified_scheme = signed_response(&schedule(false), 0, genesis(), &signer); + unspecified_scheme.current_key.as_mut().unwrap().scheme = 0; assert_matches!( - verify_transaction_encryption_key(empty_key_id, trusted), + verify_transaction_encryption_key_schedule(&unspecified_scheme, trusted), + Err(TransactionEncryptionKeyError::UnspecifiedScheme) + ); + + let mut empty_key_id = signed_response(&schedule(false), 0, genesis(), &signer); + empty_key_id.current_key.as_mut().unwrap().key_id.clear(); + assert_matches!( + verify_transaction_encryption_key_schedule(&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]; + let mut oversized_key_id = signed_response(&schedule(false), 0, genesis(), &signer); + oversized_key_id.current_key.as_mut().unwrap().key_id = vec![0; MAX_KEY_ID_LEN + 1]; assert_matches!( - verify_transaction_encryption_key(oversized_key_id, trusted), + verify_transaction_encryption_key_schedule(&oversized_key_id, trusted), Err(TransactionEncryptionKeyError::KeyIdTooLong { .. }) ); - let mut invalid_public_key = signed_encryption_key(&signer, genesis()); - invalid_public_key.public_key.clear(); + let mut invalid_public_key = signed_response(&schedule(false), 0, genesis(), &signer); + invalid_public_key.current_key.as_mut().unwrap().public_key.clear(); assert_matches!( - verify_transaction_encryption_key(invalid_public_key, trusted), + verify_transaction_encryption_key_schedule(&invalid_public_key, trusted), Err(TransactionEncryptionKeyError::InvalidEncryptionPublicKey { .. }) ); + + let mut missing_current_key = signed_response(&schedule(false), 0, genesis(), &signer); + missing_current_key.current_key = None; + assert_matches!( + verify_transaction_encryption_key_schedule(&missing_current_key, trusted), + Err(TransactionEncryptionKeyError::MissingCurrentKey) + ); + + let mut missing_next_key = signed_response(&schedule(true), 0, genesis(), &signer); + missing_next_key.next_key.as_mut().unwrap().key = None; + assert_matches!( + verify_transaction_encryption_key_schedule(&missing_next_key, trusted), + Err(TransactionEncryptionKeyError::MissingNextKey) + ); + } + + /// Pins the attestation transcript byte-for-byte, which also pins *which* fields it binds. + #[test] + fn attestation_transcript_is_stable() { + let schedule = schedule(true); + let commitment = schedule.attestation_commitment(genesis(), 3); + + let current = &schedule.current_key; + let next = schedule.next_key.as_ref().unwrap(); + let mut expected = Vec::new(); + expected.extend_from_slice(b"MIDEN_TX_ENCRYPTION_KEY_SCHEDULE_ATTESTATION_V2"); + expected.extend_from_slice(&genesis().to_bytes()); + expected.extend_from_slice(&3u32.to_le_bytes()); + expected.extend_from_slice(&CURRENT_ACTIVATION.to_le_bytes()); + expected.extend_from_slice(&1u32.to_le_bytes()); + expected.extend_from_slice(&4u32.to_le_bytes()); + expected.extend_from_slice(¤t.key_id); + expected.extend_from_slice(&32u32.to_le_bytes()); + expected.extend_from_slice(¤t.public_key); + expected.push(1); + expected.extend_from_slice(&next_activation().as_u32().to_le_bytes()); + expected.extend_from_slice(&1u32.to_le_bytes()); + expected.extend_from_slice(&4u32.to_le_bytes()); + expected.extend_from_slice(&next.key.key_id); + expected.extend_from_slice(&32u32.to_le_bytes()); + expected.extend_from_slice(&next.key.public_key); + + assert_eq!(commitment, miden_protocol::Hasher::hash(&expected)); } - /// Pins the transcript byte-for-byte, which also pins *which* fields it binds. + /// Pins the sealing 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 diff --git a/crates/rpc/src/server/api/get_transaction_encryption_key.rs b/crates/rpc/src/server/api/get_transaction_encryption_key.rs index 958c69c9e3..dac47cc5f2 100644 --- a/crates/rpc/src/server/api/get_transaction_encryption_key.rs +++ b/crates/rpc/src/server/api/get_transaction_encryption_key.rs @@ -8,13 +8,15 @@ use crate::{COMPONENT, LOG_TARGET}; #[tonic::async_trait] impl proto::server::rpc_api::GetTransactionEncryptionKey for RpcService { type Input = (); - type Output = proto::transaction::TransactionEncryptionKey; + type Output = proto::transaction::TransactionEncryptionKeyResponse; fn decode(request: ()) -> tonic::Result { Ok(request) } - fn encode(output: Self::Output) -> tonic::Result { + fn encode( + output: Self::Output, + ) -> tonic::Result { Ok(output) } diff --git a/crates/rpc/src/tests.rs b/crates/rpc/src/tests.rs index fa51565966..0c256b3af7 100644 --- a/crates/rpc/src/tests.rs +++ b/crates/rpc/src/tests.rs @@ -673,7 +673,7 @@ async fn start_source_rpc( /// other RPC. #[derive(Clone)] struct FixedValidator { - encryption_key: proto::transaction::TransactionEncryptionKey, + encryption_key: proto::transaction::TransactionEncryptionKeyResponse, call_count: Arc, last_accept: Arc>>, } @@ -681,13 +681,15 @@ struct FixedValidator { #[tonic::async_trait] impl validator_api::GetTransactionEncryptionKey for FixedValidator { type Input = (); - type Output = proto::transaction::TransactionEncryptionKey; + type Output = proto::transaction::TransactionEncryptionKeyResponse; fn decode(request: ()) -> tonic::Result { Ok(request) } - fn encode(output: Self::Output) -> tonic::Result { + fn encode( + output: Self::Output, + ) -> tonic::Result { Ok(output) } @@ -804,7 +806,7 @@ impl validator_api::BlockSubscription for FixedValidator { /// Serves a [`FixedValidator`] on an ephemeral port and returns a connected client together with /// the stub's call counter and the last ACCEPT header it observed. async fn start_validator( - encryption_key: proto::transaction::TransactionEncryptionKey, + encryption_key: proto::transaction::TransactionEncryptionKeyResponse, ) -> (ValidatorClient, Arc, Arc>>) { let listener = TcpListener::bind("127.0.0.1:0").await.expect("Failed to bind validator"); let addr = listener.local_addr().expect("Failed to get validator address"); @@ -837,21 +839,27 @@ async fn start_validator( /// A fixed transaction encryption key response for forwarding tests. The values only need to /// survive the passthrough unchanged. -fn test_encryption_key() -> proto::transaction::TransactionEncryptionKey { - proto::transaction::TransactionEncryptionKey { - scheme: proto::transaction::IesScheme::X25519Xchacha20Poly1305 as i32, - key_id: vec![0xDE, 0xAD, 0xBE, 0xEF], - public_key: vec![7; 32], +fn test_encryption_key() -> proto::transaction::TransactionEncryptionKeyResponse { + proto::transaction::TransactionEncryptionKeyResponse { + current_key: Some(proto::transaction::TransactionEncryptionKey { + scheme: proto::transaction::IesScheme::X25519Xchacha20Poly1305 as i32, + key_id: vec![0xDE, 0xAD, 0xBE, 0xEF], + public_key: vec![7; 32], + }), + next_key: Some(proto::transaction::NextTransactionEncryptionKey { + key: Some(proto::transaction::TransactionEncryptionKey { + scheme: proto::transaction::IesScheme::X25519Xchacha20Poly1305 as i32, + key_id: vec![0xFE, 0xED], + public_key: vec![6; 32], + }), + activation_block_num: 1 << 16, + }), + current_key_activation_block_num: 0, + attestation_epoch: 0, attestations: vec![proto::transaction::ValidatorKeyAttestation { validator_public_key: vec![8; 33], signature: vec![9; 65], }], - next_key: Some(proto::transaction::NextTransactionEncryptionKey { - scheme: proto::transaction::IesScheme::X25519Xchacha20Poly1305 as i32, - key_id: vec![0xFE, 0xED], - public_key: vec![6; 32], - rotation_block_num: 42, - }), } } diff --git a/docs/external/src/network-operator/validator.md b/docs/external/src/network-operator/validator.md index 8248825eb4..fa11d24cf8 100644 --- a/docs/external/src/network-operator/validator.md +++ b/docs/external/src/network-operator/validator.md @@ -45,15 +45,27 @@ 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, 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 -`MIDEN_VALIDATOR_ENCRYPTION_KEY_KMS_CIPHERTEXT`. The validator recovers the key material at startup with `kms:Decrypt`, -so its AWS identity needs that permission on the wrapping key. Note that, unlike KMS-backed signing, the decrypted -encryption key is held in validator memory: AWS KMS cannot perform X25519 key agreement itself, so envelope encryption -is the supported provisioning path. +every validator in the set. The validator does not derive replacement keys or rotate it automatically. The validator +logs a warning at startup if the insecure development default is in use, and another if a key was loaded from plain hex +rather than a KMS ciphertext. + +The validator accepts an optional previous key for grace decryption and an optional next key for a planned rotation. +Each key has an activation block, which must be an epoch boundary. Configure these with `--encryption-key.previous.*`, +`--encryption-key.activation-block`, and `--encryption-key.next.*`. Hex and KMS ciphertext sources are supported for all +three keys. + +All validators must restart with the same key schedule whenever the configured keys change. Keys then activate at their +epoch boundaries without another restart. For example, a restart that activates key B and announces key C uses A as the +previous key, B as the current key, and C as the next key. The provider accepts A through B's activation epoch, then +marks it expired. After that epoch, a restart can drop A. When C activates, move B into the previous slot. + +The validator logs a warning when any key is loaded from plain hex. Production deployments should instead wrap each key +with a symmetric AWS KMS key (`aws kms encrypt`) and pass the resulting base64 ciphertext blob unchanged via +`--encryption-key.kms-ciphertext` or `MIDEN_VALIDATOR_ENCRYPTION_KEY_KMS_CIPHERTEXT`. The validator recovers the key +material at startup with `kms:Decrypt`, so its AWS identity needs that permission on the wrapping key. Note that, unlike +KMS-backed signing, the decrypted encryption key is held in validator memory: AWS KMS cannot perform X25519 key +agreement itself, so envelope encryption is the supported provisioning path. Other providers may keep the secret outside +the validator process because the provider contract requires only public schedule metadata and a decrypt operation. Each validator must run inside its trusted execution environment. If transaction proving uses a remote prover, that prover also receives the plaintext inputs and must run inside the same trusted boundary. diff --git a/docs/external/src/rpc/public-api.md b/docs/external/src/rpc/public-api.md index f2c73c72e4..f37cc3b5e2 100644 --- a/docs/external/src/rpc/public-api.md +++ b/docs/external/src/rpc/public-api.md @@ -32,22 +32,34 @@ grpcurl rpc.testnet.miden.io:443 describe rpc.Api ## Transaction Submission -| Method | Purpose | -| ----------------------------- | ------------------------------------------------------------------------------------------- | -| `GetTransactionEncryptionKey` | Returns the transaction encryption public key, attested by a validator's signing key. | -| `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. | +| Method | Purpose | +| ----------------------------- | ------------------------------------------------------------------------------------------------------- | +| `GetTransactionEncryptionKey` | Returns the current and next transaction encryption public keys, attested by a validator's signing key. | +| `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, 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. +The response carries the current key, its activation block, and an optional manually scheduled next key. Activations +must be epoch boundaries. One validator signature covers the complete schedule, including whether the next key is +absent, so an untrusted RPC cannot remove a scheduled key without invalidating the signature. + +Clients should use `miden_node_proto::domain::encryption` to verify the response against a genesis commitment, trusted +chain tip, and validator signing keys. The signed attestation epoch must match the trusted tip's epoch, which rejects +responses replayed from an earlier epoch. The verifier also rejects premature current keys, already-active next keys, +and activations that are not epoch boundaries. Key IDs are opaque provider-owned bytes and must be sent back with +encrypted submissions so the provider can select the intended key. + +Providers keep a schedule unchanged within an epoch. Preventing a validator from signing two different schedules in one +epoch requires this operational rule until the schedule has its own on-chain commitment. + +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 `ValidatorKeyAttestation` and `SealedTransactionInputs` proto messages. + +This scheme does not hide transaction inputs from holders of the shared encryption secret and provides no forward +secrecy. 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 5e1ea5a9e2..21b404d1b2 100644 --- a/docs/internal/src/validator.md +++ b/docs/internal/src/validator.md @@ -29,17 +29,34 @@ Once verified, the block is signed and returned to the sender. ## Transaction encryption key 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 use it to encrypt the private transaction inputs they +transaction encryption provider, which holds an Ed25519 key that miden-crypto uses for X25519 +key 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 -scheme identifier, an opaque key ID, and a list of validator attestations, currently holding one -signature from this validator's own signing key over an attestation commitment (the -`TransactionEncryptionKey` proto message documents the exact payload). The commitment carries a domain tag that separates attestations from block header -signatures, and the genesis commitment so an attestation cannot replay across networks. The -signature proves to clients that a chain-recognized validator vouches for the key, so the key can -be served through an untrusted RPC. +The provider owns its opaque key IDs and secret storage. It stores an optional previous key, a +current key, and an optional manually selected next key. It exposes only the current and next +public metadata, and it decrypts using the key ID supplied by the caller. It does not expose raw +secret bytes. + +A scheduled key may activate only at an epoch boundary. Before that boundary its ID is premature. +At the boundary it becomes current and the prior current key remains decrypt-only through the +activation epoch. The old ID is expired from the following epoch boundary onward. The provider, +not the validator service, enforces these rules. The validator does not derive keys or choose an +automatic rotation policy. Providers keep the schedule fixed within each epoch. Operators publish +a manually selected next key only at an epoch boundary and restart all validators with the same +previous, current, and next state. + +`GetTransactionEncryptionKey` returns the current key and optional next key as one schedule. A +single validator signature binds the complete schedule, including both activation blocks and the +presence or absence of the next key. It also binds the genesis commitment and an attestation +epoch. The validator lazily refreshes this attestation at most once per epoch without changing +the provider schedule. + +The canonical typed verifier lives in +`miden_node_proto::domain::transaction_encryption`. It checks the signature against +chain-recognized validator keys, requires the attestation epoch to match the trusted chain tip, +and enforces activation boundaries. This lets node-owned clients reject cross-network, stale, +premature, and structurally altered schedules 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 diff --git a/proto/proto/internal/validator.proto b/proto/proto/internal/validator.proto index c7d7d09b18..d2acab9f51 100644 --- a/proto/proto/internal/validator.proto +++ b/proto/proto/internal/validator.proto @@ -31,10 +31,12 @@ service Api { // Returns the public key that clients use to encrypt the private inputs of the transactions // they submit, attested by this validator's own signing key. // - // The encryption key is shared across the whole validator set, so the returned public key is - // identical regardless of which validator serves the request. The attestation carried in the - // response is specific to this validator. - rpc GetTransactionEncryptionKey(google.protobuf.Empty) returns (transaction.TransactionEncryptionKey) {} + // The provider-owned keys are shared across the validator set, so the returned schedule is + // identical regardless of which validator serves the request. The schedule attestation is + // specific to this validator and refreshed once per epoch for replay protection. + // + // Rotations are scheduled manually and may activate only at epoch boundaries. + rpc GetTransactionEncryptionKey(google.protobuf.Empty) returns (transaction.TransactionEncryptionKeyResponse) {} } // BLOCK SUBSCRIPTION diff --git a/proto/proto/rpc.proto b/proto/proto/rpc.proto index 66f9468f45..282e3b00a8 100644 --- a/proto/proto/rpc.proto +++ b/proto/proto/rpc.proto @@ -46,12 +46,13 @@ service Api { // Returns the public key that clients use to encrypt the private inputs of the transactions // they submit, so that validators can decrypt and validate them. // - // Every validator in the set is provisioned with the same encryption keypair, so the returned - // public key is identical regardless of which validator attests it. The key carries a list of - // validator attestations, currently containing a single one. Since all validators vouch for - // the same key, an attestation verifiable against any chain-recognized validator signing key - // is sufficient. - rpc GetTransactionEncryptionKey(google.protobuf.Empty) returns (transaction.TransactionEncryptionKey) {} + // Every validator in the set uses the same provider-owned key schedule. The complete current + // and optional-next schedule carries validator attestations, currently containing a single + // one. An attestation verifiable against any chain-recognized validator signing key is + // sufficient. + // + // Rotations are scheduled manually and may activate only at epoch boundaries. + rpc GetTransactionEncryptionKey(google.protobuf.Empty) returns (transaction.TransactionEncryptionKeyResponse) {} // Submits proven transaction to the Miden network. Returns the node's current block height. rpc SubmitProvenTx(transaction.ProvenTransaction) returns (blockchain.BlockNumber) {} diff --git a/proto/proto/types/transaction.proto b/proto/proto/types/transaction.proto index c95d2c41a6..e9eca063d3 100644 --- a/proto/proto/types/transaction.proto +++ b/proto/proto/types/transaction.proto @@ -87,7 +87,7 @@ enum IesScheme { IES_SCHEME_X25519_XCHACHA20_POLY1305 = 1; } -// A single validator's attestation of the transaction encryption key. +// A single validator's attestation of a complete transaction encryption key schedule. message ValidatorKeyAttestation { // Public key of the attesting validator, encoded using the [miden_serde_utils::Serializable] // implementation for [miden_protocol::crypto::dsa::ecdsa_k256_keccak::PublicKey]. Must match a @@ -95,39 +95,25 @@ message ValidatorKeyAttestation { bytes validator_public_key = 1; // The validator's signature over the attestation commitment: the Poseidon2 byte-mode hash of - // `domain_tag || scheme || len(key_id) || key_id || genesis_commitment || len(public_key) || - // public_key || next_key_transcript`, where `domain_tag` is the ASCII string - // `MIDEN_TX_ENCRYPTION_KEY_ATTESTATION_V1`, the scheme, the rotation block number, and the - // length prefixes are encoded as 4 bytes little-endian, and the genesis block commitment ties - // the attestation to one network. - // `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_node_proto::domain::encryption`. + // `domain_tag || genesis_commitment || attestation_epoch || current_activation || current_key || + // next_key_present || next_key_transcript`, where `domain_tag` is the ASCII string + // `MIDEN_TX_ENCRYPTION_KEY_SCHEDULE_ATTESTATION_V2` and the genesis block commitment ties the + // attestation to one network. + // + // Each key contributes `scheme || len(key_id) || key_id || len(public_key) || public_key`, with + // the scheme, the epoch, the block numbers and the length prefixes encoded as 4 bytes + // little-endian. `next_key_present` is a single byte, followed by the scheduled key's + // `activation_block_num || key` when it is set, so a scheduled rotation can neither be stripped + // nor injected without invalidating the signature. The canonical construction and verifier live + // in `miden_node_proto::domain::encryption`. bytes signature = 2; } -// The next transaction encryption key, announced ahead of a scheduled key rotation. -message NextTransactionEncryptionKey { - // IES scheme the next encryption key belongs to. - IesScheme scheme = 1; - - // Opaque identifier of the next encryption key. - bytes key_id = 2; - - // Raw public key bytes of the next encryption key, in the same encoding as - // `TransactionEncryptionKey.public_key`. - bytes public_key = 3; - - // Block number at which the next key replaces the current one. - fixed32 rotation_block_num = 4; -} - -// The shared transaction encryption key, attested by validators. +// One shared transaction encryption key. // -// The public key is shared across the whole validator set, while each attesting signature is -// specific to one validator. Verifying an attestation against a chain-recognized validator -// signing key proves the encryption key was vouched for by a legitimate validator. +// The public key is shared across the whole validator set. Attestations are not carried here but +// on `TransactionEncryptionKeyResponse`, because a validator signs the complete schedule rather +// than any single key. message TransactionEncryptionKey { // IES scheme the encryption key belongs to. // @@ -144,23 +130,41 @@ message TransactionEncryptionKey { // key that miden-crypto converts internally for X25519 key agreement). bytes public_key = 3; - // Validator attestations of this key (and of `next_key` when set). - // - // Currently contains a single attestation from the validator that served the request. - // Collecting attestations from the whole validator set requires validator intercommunication - // and is planned as a follow-up; the wire format already accommodates it. - repeated ValidatorKeyAttestation attestations = 4; + reserved 4, 5; + reserved "attestations", "next_key"; +} - // Set when a key rotation is scheduled: the key that replaces the current one, and the block - // number at which it takes effect. Covered by the attestation commitment, so it cannot be - // stripped or altered without invalidating the signatures. - // - // Never set currently; key rotation is not implemented yet. - optional NextTransactionEncryptionKey next_key = 5; +// The next transaction encryption key, announced ahead of a scheduled key rotation. +message NextTransactionEncryptionKey { + // The key that replaces the current one at the activation block. + TransactionEncryptionKey key = 1; + + // Block number at which the next key replaces the current one. Must be an epoch boundary. + fixed32 activation_block_num = 2; +} - // Reserved for future attestation evidence beyond the validator signatures, e.g. a TEE quote, - // signature chain, compose hash, app measurement, epoch, or accepted measurement set. - reserved 6 to 9; +// Response to a transaction encryption key request: the current key and the key scheduled to +// replace it. +message TransactionEncryptionKeyResponse { + // The encryption key currently in effect. + TransactionEncryptionKey current_key = 1; + + // The key that replaces `current_key` at its activation block when a manual rotation has been + // scheduled. + optional NextTransactionEncryptionKey next_key = 2; + + // Epoch boundary at which the current key became active. + fixed32 current_key_activation_block_num = 3; + + // Epoch in which this schedule was attested. A verifier must compare this with a trusted chain + // tip so a schedule from an earlier epoch cannot be replayed. + fixed32 attestation_epoch = 4; + + // Validator attestations over this complete schedule. + // + // Currently contains a single attestation from the validator that served the request. + // Collecting attestations from the whole validator set requires validator intercommunication. + repeated ValidatorKeyAttestation attestations = 5; } // Represents a transaction ID.