diff --git a/.config/cspell.yaml b/.config/cspell.yaml index 45e2c2166e..6acc3c6963 100644 --- a/.config/cspell.yaml +++ b/.config/cspell.yaml @@ -13,6 +13,7 @@ ignorePaths: - node_modules - "**/package-lock.json" words: + - ciphertext - Devnet - grpcurl - Merkle diff --git a/Cargo.lock b/Cargo.lock index 414a690fcb..40ceaea053 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3785,6 +3785,7 @@ dependencies = [ "anyhow", "aws-config", "aws-sdk-kms", + "base64", "build-rs", "clap", "fs-err", @@ -3796,6 +3797,7 @@ dependencies = [ "miden-node-utils", "miden-protocol", "miden-tx", + "rand 0.10.2", "tempfile", "thiserror 2.0.18", "tokio", diff --git a/bin/validator/Cargo.toml b/bin/validator/Cargo.toml index 0eb4002a6a..8946a77cf1 100644 --- a/bin/validator/Cargo.toml +++ b/bin/validator/Cargo.toml @@ -21,6 +21,7 @@ doctest = false anyhow = { workspace = true } aws-config = { version = "1.8.14" } aws-sdk-kms = { version = "1.100" } +base64 = { version = "0.22" } clap = { features = ["env", "string"], workspace = true } fs-err = { workspace = true } hex = { workspace = true } @@ -47,5 +48,6 @@ miden-node-db = { workspace = true } miden-node-store = { workspace = true } miden-node-utils = { features = ["testing"], workspace = true } miden-protocol = { default-features = true, features = ["testing"], workspace = true } +rand = { workspace = true } tempfile = { workspace = true } tokio = { features = ["macros", "rt-multi-thread"], workspace = true } diff --git a/bin/validator/src/commands/bootstrap.rs b/bin/validator/src/commands/bootstrap.rs index 37a568ffd4..96460c18bb 100644 --- a/bin/validator/src/commands/bootstrap.rs +++ b/bin/validator/src/commands/bootstrap.rs @@ -8,7 +8,7 @@ use miden_node_utils::fs::ensure_empty_directory; use miden_protocol::utils::serde::Serializable; use miden_validator::{DataDirectory, ValidatorSigner}; -use super::ValidatorKey; +use super::ValidatorSigningKey; // Bootstraps the validator component. pub async fn bootstrap( @@ -17,7 +17,7 @@ pub async fn bootstrap( data_directory: &Path, sqlite_connection_pool_size: NonZeroUsize, genesis_config: Option<&PathBuf>, - validator_key: ValidatorKey, + signing_key: ValidatorSigningKey, ) -> anyhow::Result<()> { let config = genesis_config .map(|file_path| { @@ -32,7 +32,7 @@ pub async fn bootstrap( ensure_empty_directory(directory)?; } - let signer = validator_key.into_signer().await?; + let signer = signing_key.into_signer().await?; let dirs = DataDirectory::load_bootstrap( genesis_block_directory.to_path_buf(), accounts_directory.to_path_buf(), @@ -68,7 +68,7 @@ async fn build_and_write_genesis( .into_unsigned_block() .context("failed to build the unsigned genesis block")?; let signature = signer - .sign(unsigned_genesis_block.header()) + .sign_commitment(unsigned_genesis_block.header().commitment()) .await .context("failed to sign the genesis block")?; let genesis_block = unsigned_genesis_block diff --git a/bin/validator/src/commands/mod.rs b/bin/validator/src/commands/mod.rs index ef76d9ce6b..15c065e167 100644 --- a/bin/validator/src/commands/mod.rs +++ b/bin/validator/src/commands/mod.rs @@ -3,27 +3,42 @@ mod start; use std::num::NonZeroUsize; use std::path::PathBuf; +use std::sync::Arc; use anyhow::Context; +use base64::Engine; use clap::Parser; use miden_node_utils::clap::GrpcOptionsInternal; use miden_node_utils::logging::OpenTelemetry; use miden_node_utils::shutdown::CancellationToken; use miden_protocol::crypto::dsa::ecdsa_k256_keccak::SigningKey; +use miden_protocol::crypto::dsa::eddsa_25519_sha512::KeyExchangeKey; use miden_protocol::utils::serde::Deserializable; -use miden_validator::{DataDirectory, ValidatorSigner}; +use miden_validator::{ + DataDirectory, + LOG_TARGET, + LocalX25519TransactionInputDecrypter, + TransactionInputDecrypter, + ValidatorSigner, +}; const ENV_DATA_DIRECTORY: &str = "MIDEN_VALIDATOR_DATA_DIRECTORY"; const ENV_LISTEN: &str = "MIDEN_VALIDATOR_LISTEN"; -const ENV_KEY: &str = "MIDEN_VALIDATOR_KEY"; -const ENV_KMS_KEY_ID: &str = "MIDEN_VALIDATOR_KMS_KEY_ID"; +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_GENESIS_CONFIG_FILE: &str = "MIDEN_VALIDATOR_GENESIS_CONFIG_FILE"; const ENV_SQLITE_CONNECTION_POOL_SIZE: &str = "MIDEN_VALIDATOR_SQLITE_CONNECTION_POOL_SIZE"; -/// A predefined, insecure validator key for development purposes. -pub(crate) const INSECURE_KEY_HEX: &str = +/// A predefined, insecure validator signing key for development purposes. +pub(crate) const INSECURE_SIGNING_KEY_HEX: &str = "0101010101010101010101010101010101010101010101010101010101010101"; +/// A predefined, insecure shared transaction encryption key for development purposes. +pub(crate) const INSECURE_ENCRYPTION_KEY_HEX: &str = + "0202020202020202020202020202020202020202020202020202020202020202"; + // VALIDATOR COMMAND // ================================================================================================ @@ -56,9 +71,9 @@ pub enum ValidatorCommand { /// Use the given configuration file to construct the genesis state from. #[arg(long, env = ENV_GENESIS_CONFIG_FILE, value_name = "GENESIS_CONFIG")] genesis_config_file: Option, - /// Configuration for the Validator key used to sign the genesis block. + /// Configuration for the validator signing key used to sign the genesis block. #[command(flatten)] - validator_key: ValidatorKey, + signing_key: ValidatorSigningKey, }, /// Applies pending validator database migrations. @@ -96,26 +111,59 @@ pub enum ValidatorCommand { /// /// If not provided, a predefined key is used. /// - /// Cannot be used with `key.kms-id`. + /// Cannot be used with `signing-key.kms-id`. #[arg( - long = "key.hex", - env = ENV_KEY, - value_name = "VALIDATOR_KEY", - default_value = INSECURE_KEY_HEX, - group = "key" + long = "signing-key.hex", + env = ENV_SIGNING_KEY, + value_name = "VALIDATOR_SIGNING_KEY", + default_value = INSECURE_SIGNING_KEY_HEX, + group = "signing_key_source" )] - validator_key: String, + signing_key: String, /// Key ID for the KMS key used by validator to sign blocks. /// - /// Cannot be used with `key.hex`. + /// Cannot be used with `signing-key.hex`. + #[arg( + long = "signing-key.kms-id", + env = ENV_SIGNING_KEY_KMS_ID, + value_name = "VALIDATOR_SIGNING_KEY_KMS_ID", + group = "signing_key_source" + )] + 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 = "key.kms-id", - env = ENV_KMS_KEY_ID, - value_name = "VALIDATOR_KMS_KEY_ID", - group = "key" + long = "encryption-key.kms-ciphertext", + env = ENV_ENCRYPTION_KEY_KMS_CIPHERTEXT, + value_name = "VALIDATOR_ENCRYPTION_KEY_KMS_CIPHERTEXT", + group = "encryption_key_source" )] - kms_key_id: Option, + encryption_key_kms_ciphertext: Option, }, } @@ -128,7 +176,7 @@ impl ValidatorCommand { data_directory, sqlite_connection_pool_size, genesis_config_file, - validator_key, + signing_key, } => { bootstrap::bootstrap( &genesis_block_directory, @@ -136,7 +184,7 @@ impl ValidatorCommand { &data_directory, sqlite_connection_pool_size, genesis_config_file.as_ref(), - validator_key, + signing_key, ) .await }, @@ -150,38 +198,62 @@ impl ValidatorCommand { Self::Start { listen, grpc_options, - validator_key, + signing_key, data_directory, - kms_key_id, + signing_key_kms_id, sqlite_connection_pool_size, + encryption_key, + encryption_key_kms_ciphertext, .. } => { let address = listen; - if let Some(kms_key_id) = kms_key_id { - let signer = ValidatorSigner::new_kms(kms_key_id).await?; - start::start( - address, - grpc_options, - signer, - data_directory, - sqlite_connection_pool_size, - shutdown, - ) - .await + 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 { - let signer = SigningKey::read_from_bytes(hex::decode(validator_key)?.as_ref())?; - let signer = ValidatorSigner::new_local(signer); - start::start( - address, - grpc_options, - signer, - data_directory, - sqlite_connection_pool_size, - shutdown, - ) - .await - } + // 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")?; + let decrypter: Arc = + Arc::new(LocalX25519TransactionInputDecrypter::new(encryption_key)); + + let signer = if let Some(kms_key_id) = signing_key_kms_id { + ValidatorSigner::new_kms(kms_key_id).await? + } else { + let signer = SigningKey::read_from_bytes(hex::decode(signing_key)?.as_ref())?; + ValidatorSigner::new_local(signer) + }; + + start::start( + address, + grpc_options, + signer, + decrypter, + data_directory, + sqlite_connection_pool_size, + shutdown, + ) + .await }, } } @@ -194,43 +266,105 @@ impl ValidatorCommand { } } -// VALIDATOR KEY +// VALIDATOR SIGNING KEY // ================================================================================================ -/// Configuration for the Validator key used to sign blocks. +/// Configuration for the validator signing key used to sign blocks. #[derive(clap::Args)] #[group(required = false, multiple = false)] -pub struct ValidatorKey { +pub struct ValidatorSigningKey { /// Insecure, hex-encoded validator secret key for development and testing purposes. /// /// If not provided, a predefined key is used. /// - /// Cannot be used with `key.kms-id`. + /// Cannot be used with `signing-key.kms-id`. #[arg( - long = "key.hex", - env = ENV_KEY, - value_name = "VALIDATOR_KEY", - default_value = INSECURE_KEY_HEX, + long = "signing-key.hex", + env = ENV_SIGNING_KEY, + value_name = "VALIDATOR_SIGNING_KEY", + default_value = INSECURE_SIGNING_KEY_HEX, )] - pub validator_key: String, + pub signing_key: String, /// Key ID for the KMS key used by validator to sign blocks. /// - /// Cannot be used with `key.hex`. + /// Cannot be used with `signing-key.hex`. #[arg( - long = "key.kms-id", - env = ENV_KMS_KEY_ID, - value_name = "VALIDATOR_KMS_KEY_ID", + long = "signing-key.kms-id", + env = ENV_SIGNING_KEY_KMS_ID, + value_name = "VALIDATOR_SIGNING_KEY_KMS_ID", )] - pub validator_kms_key_id: Option, + pub signing_key_kms_id: Option, } -impl ValidatorKey { +impl ValidatorSigningKey { pub async fn into_signer(self) -> anyhow::Result { - if let Some(kms_key_id) = self.validator_kms_key_id { + if let Some(kms_key_id) = self.signing_key_kms_id { Ok(ValidatorSigner::new_kms(kms_key_id).await?) } else { - let signer = SigningKey::read_from_bytes(hex::decode(self.validator_key)?.as_ref())?; + let signer = SigningKey::read_from_bytes(hex::decode(self.signing_key)?.as_ref())?; Ok(ValidatorSigner::new_local(signer)) } } } + +// TESTS +// ================================================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + const BASE_START_ARGS: [&str; 6] = [ + "miden-validator", + "start", + "--listen", + "127.0.0.1:50101", + "--data-directory", + "/tmp/validator-data", + ]; + + fn parse_start(extra: &[&str]) -> Result { + ValidatorCommand::try_parse_from( + BASE_START_ARGS.iter().copied().chain(extra.iter().copied()), + ) + } + + #[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 { + panic!("expected the start command"); + }; + assert_eq!(encryption_key, INSECURE_ENCRYPTION_KEY_HEX); + assert_eq!(encryption_key_kms_ciphertext, None); + } + + #[test] + 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 { + panic!("expected the start command"); + }; + assert_eq!(encryption_key_kms_ciphertext.as_deref(), Some("deadbeef")); + } + + #[test] + fn encryption_key_hex_and_kms_ciphertext_conflict() { + let result = parse_start(&[ + "--encryption-key.hex", + INSECURE_ENCRYPTION_KEY_HEX, + "--encryption-key.kms-ciphertext", + "deadbeef", + ]); + let Err(error) = result else { + panic!("hex key and KMS ciphertext together must be rejected"); + }; + assert_eq!(error.kind(), clap::error::ErrorKind::ArgumentConflict); + } +} diff --git a/bin/validator/src/commands/start.rs b/bin/validator/src/commands/start.rs index 62adecf64f..f93fe8a016 100644 --- a/bin/validator/src/commands/start.rs +++ b/bin/validator/src/commands/start.rs @@ -1,17 +1,19 @@ use std::net::SocketAddr; use std::num::NonZeroUsize; use std::path::PathBuf; +use std::sync::Arc; use anyhow::Context; use miden_node_utils::clap::GrpcOptionsInternal; use miden_node_utils::shutdown::CancellationToken; -use miden_validator::{DataDirectory, ValidatorServer, ValidatorSigner}; +use miden_validator::{DataDirectory, TransactionInputDecrypter, ValidatorServer, ValidatorSigner}; // Starts the validator component. pub async fn start( address: SocketAddr, grpc_options: GrpcOptionsInternal, signer: ValidatorSigner, + decrypter: Arc, data_directory: PathBuf, sqlite_connection_pool_size: NonZeroUsize, shutdown: CancellationToken, @@ -22,6 +24,7 @@ pub async fn start( address, grpc_options, signer, + decrypter, data_directory, sqlite_connection_pool_size, } diff --git a/bin/validator/src/lib.rs b/bin/validator/src/lib.rs index cf0dd7c9fa..fce827980e 100644 --- a/bin/validator/src/lib.rs +++ b/bin/validator/src/lib.rs @@ -6,7 +6,16 @@ mod tx_validation; pub use data_directory::DataDirectory; pub use server::ValidatorServer; -pub use signers::{KmsSigner, ValidatorSigner}; +pub use signers::{ + KmsSigner, + LocalX25519TransactionInputDecrypter, + NextEncryptionKeyInfo, + TransactionEncryptionKeyInfo, + TransactionInputDecrypter, + ValidatorSigner, + attestation_commitment, + decrypt_key_material, +}; // CONSTANTS // ================================================================================================= diff --git a/bin/validator/src/server/mod.rs b/bin/validator/src/server/mod.rs index 139e2d9517..7a2979b198 100644 --- a/bin/validator/src/server/mod.rs +++ b/bin/validator/src/server/mod.rs @@ -20,7 +20,7 @@ use crate::db::{ load_chain_tip, load_with_pool_size, }; -use crate::{DataDirectory, LOG_TARGET, ValidatorSigner}; +use crate::{DataDirectory, LOG_TARGET, TransactionInputDecrypter, ValidatorSigner}; mod validator_service; @@ -43,6 +43,10 @@ pub struct ValidatorServer { /// The signer used to sign blocks. pub signer: ValidatorSigner, + /// The decrypter for the shared transaction encryption key, used to unseal encrypted + /// transaction inputs. + pub decrypter: std::sync::Arc, + /// The data directory for the validator component's database files. pub data_directory: DataDirectory, @@ -98,6 +102,7 @@ impl ValidatorServer { .add_service(validator_api::service( ValidatorService::new( self.signer, + self.decrypter, db, block_store, initial_chain_tip, 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 new file mode 100644 index 0000000000..d916eb4c18 --- /dev/null +++ b/bin/validator/src/server/validator_service/get_transaction_encryption_key.rs @@ -0,0 +1,54 @@ +use miden_node_proto::generated as grpc; +use miden_node_utils::tracing::miden_instrument; +use miden_tx::utils::serde::Serializable; + +use super::ValidatorService; +use crate::COMPONENT; + +#[tonic::async_trait] +impl grpc::server::validator_api::GetTransactionEncryptionKey for ValidatorService { + type Input = (); + type Output = grpc::transaction::TransactionEncryptionKey; + + fn decode(request: ()) -> tonic::Result { + Ok(request) + } + + fn encode(output: Self::Output) -> tonic::Result { + Ok(output) + } + + #[miden_instrument( + target = COMPONENT, + name = "get_transaction_encryption_key", + skip_all, + err, + )] + async fn handle( + &self, + _input: Self::Input, + _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: i32::try_from(self.encryption_key_info.scheme) + .expect("scheme identifier must fit in i32"), + key_id: self.encryption_key_info.key_id.clone(), + public_key: self.encryption_key_info.public_key.clone(), + attestations: vec![grpc::transaction::ValidatorKeyAttestation { + validator_public_key: self.signer.public_key().to_bytes(), + signature: self.encryption_key_attestation.to_bytes(), + }], + next_key: self.encryption_key_info.next_key.as_ref().map(|next| { + grpc::transaction::NextTransactionEncryptionKey { + scheme: i32::try_from(next.scheme).expect("scheme identifier must fit in i32"), + key_id: next.key_id.clone(), + public_key: next.public_key.clone(), + rotation_block_num: next.rotation_block_num, + } + }), + }) + } +} diff --git a/bin/validator/src/server/validator_service/mod.rs b/bin/validator/src/server/validator_service/mod.rs index 9621666945..d27c12692d 100644 --- a/bin/validator/src/server/validator_service/mod.rs +++ b/bin/validator/src/server/validator_service/mod.rs @@ -19,12 +19,14 @@ use miden_protocol::transaction::{TransactionHeader, TransactionId}; use tokio::sync::{Semaphore, watch}; use crate::db::{find_unvalidated_transactions, load_block_header, load_chain_tip}; -use crate::{COMPONENT, ValidatorSigner}; +use crate::signers::TransactionEncryptionKeyInfo; +use crate::{COMPONENT, TransactionInputDecrypter, ValidatorSigner}; #[cfg(test)] mod tests; mod block_subscription; +mod get_transaction_encryption_key; mod sign_block; mod status; mod submit_proven_transaction; @@ -61,6 +63,10 @@ pub enum ValidatorError { BlockBackupFailed(#[source] std::io::Error), #[error("expected a single-key validator set, got {actual} keys")] UnexpectedValidatorSetSize { actual: usize }, + #[error("no genesis block header exists")] + NoGenesisHeader, + #[error("failed to attest the transaction encryption key: {0}")] + EncryptionKeyAttestationFailed(String), } // VALIDATOR SERVICE @@ -71,6 +77,14 @@ pub enum ValidatorError { /// Implements the gRPC API for the validator. pub(crate) struct ValidatorService { signer: ValidatorSigner, + /// Decrypter for transaction inputs sealed against the shared encryption key. + #[expect(dead_code, reason = "used by the submit path in a follow-up PR")] + decrypter: Arc, + /// 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 @@ -93,6 +107,7 @@ pub(crate) struct ValidatorService { impl ValidatorService { pub(crate) async fn new( signer: ValidatorSigner, + decrypter: Arc, db: Database, block_store: BlockStore, initial_chain_tip: u32, @@ -121,8 +136,28 @@ impl ValidatorService { }); } + // 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. + let genesis_commitment = db + .read("load_genesis_header", |tx| load_block_header(tx, BlockNumber::GENESIS)) + .await + .map_err(ValidatorError::DatabaseError)? + .ok_or(ValidatorError::NoGenesisHeader)? + .commitment(); + 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()))?; + Ok(Self { signer, + decrypter, + encryption_key_info, + encryption_key_attestation, serve_lock: Arc::new(tokio::sync::RwLock::new(())), db: db.into(), block_store, @@ -254,7 +289,7 @@ impl ValidatorService { )] async fn sign_header(&self, header: &BlockHeader) -> Result { self.signer - .sign(header) + .sign_commitment(header.commitment()) .await .map_err(|err| ValidatorError::BlockSigningFailed(err.to_string())) } diff --git a/bin/validator/src/server/validator_service/tests.rs b/bin/validator/src/server/validator_service/tests.rs index 99c9cfe2ab..15e0f57e84 100644 --- a/bin/validator/src/server/validator_service/tests.rs +++ b/bin/validator/src/server/validator_service/tests.rs @@ -6,18 +6,31 @@ use miden_node_store::{BlockStore, GenesisState}; use miden_node_utils::fee::test_fee_params; use miden_protocol::Word; use miden_protocol::block::{BlockHeader, BlockInputs, ProposedBlock}; -use miden_protocol::crypto::dsa::ecdsa_k256_keccak::SigningKey; +use miden_protocol::crypto::dsa::ecdsa_k256_keccak::{Signature, SigningKey}; +use miden_protocol::crypto::dsa::eddsa_25519_sha512::KeyExchangeKey; use miden_protocol::testing::random_secret_key::random_secret_key; use miden_protocol::transaction::PartialBlockchain; -use miden_tx::utils::serde::Serializable; +use miden_tx::utils::serde::{Deserializable, Serializable}; use super::{ValidatorError, ValidatorService}; -use crate::ValidatorSigner; use crate::db::{load_chain_tip, setup, upsert_block_header}; +use crate::signers::{NextEncryptionKeyInfo, attestation_commitment}; +use crate::{LocalX25519TransactionInputDecrypter, TransactionInputDecrypter, ValidatorSigner}; // TEST HELPERS // ================================================================================================ +/// The shared transaction encryption secret provisioned to every test validator. +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) +} + /// Test harness that wraps a [`Validator`] and tracks the chain MMR state needed to construct valid /// [`ProposedBlock`]s. struct TestValidator { @@ -38,7 +51,17 @@ impl TestValidator { let (temp_dir, db, block_store, genesis_header) = setup_db_with_genesis(&key).await; Self { - server: ValidatorService::new(signer, db, block_store, 0, 0, 0).await.unwrap(), + server: ValidatorService::new( + signer, + std::sync::Arc::new(test_decrypter()), + db, + block_store, + 0, + 0, + 0, + ) + .await + .unwrap(), chain: PartialBlockchain::default(), chain_tip: genesis_header, _temp_dir: temp_dir, @@ -92,6 +115,15 @@ impl TestValidator { .expect("status should always be available") } + /// Calls the `get_transaction_encryption_key` endpoint on the validator server. + async fn call_get_transaction_encryption_key( + &self, + ) -> proto::transaction::TransactionEncryptionKey { + validator_api::GetTransactionEncryptionKey::full(&self.server, tonic::Request::new(())) + .await + .expect("encryption key should always be available") + } + /// Asserts that opening a backup subscription is rejected with `resource_exhausted`. The /// success type ([`Self::ItemStream`]) is not `Debug`, so we match rather than `expect_err`. async fn assert_backup_rejected(&self, block_from: u32) { @@ -183,7 +215,16 @@ async fn signing_key_mismatch_rejected() { "test requires a signing key that differs from the genesis validator key", ); - let result = ValidatorService::new(rogue_signer, db, block_store, 0, 0, 0).await; + let result = ValidatorService::new( + rogue_signer, + std::sync::Arc::new(test_decrypter()), + db, + block_store, + 0, + 0, + 0, + ) + .await; assert!( matches!(result, Err(ValidatorError::ValidatorKeyMismatch { .. })), "expected ValidatorKeyMismatch error", @@ -690,3 +731,173 @@ async fn requests_run_concurrently() { drop(first); drop(second); } + +// 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. +#[tokio::test] +async fn transaction_encryption_key_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 = u32::try_from(response.scheme).expect("scheme must be non-negative"); + assert_eq!(scheme, info.scheme); + assert_eq!(response.key_id, info.key_id); + assert_eq!(response.public_key, info.public_key); + + let commitment = + attestation_commitment(scheme, &response.key_id, genesis, &response.public_key, None); + assert_eq!(commitment, info.attestation_commitment(genesis)); + + let [attestation] = response.attestations.as_slice() else { + panic!("response must carry exactly the serving validator's attestation"); + }; + assert_eq!( + attestation.validator_public_key, + tv.server.signer.public_key().to_bytes(), + "attestation must identify the serving validator", + ); + let signature = + Signature::read_from_bytes(&attestation.signature).expect("signature should deserialize"); + assert!( + signature.verify(commitment, &tv.server.signer.public_key()), + "attestation must verify against this validator's signing key", + ); +} + +/// Two validators provisioned with the same shared encryption secret but distinct signing keys +/// return identical public key material with different signatures. +#[tokio::test] +async fn shared_key_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_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. +#[tokio::test] +async fn tampered_attestation_fails_verification() { + let tv = TestValidator::new().await; + let genesis = tv.chain_tip.commitment(); + let response = tv.call_get_transaction_encryption_key().await; + let signature = Signature::read_from_bytes(&response.attestations[0].signature).unwrap(); + let signing_key = tv.server.signer.public_key(); + let scheme = u32::try_from(response.scheme).expect("scheme must be non-negative"); + + let mut tampered_public_key = response.public_key.clone(); + tampered_public_key[0] ^= 0x01; + let mut tampered_key_id = response.key_id.clone(); + tampered_key_id[0] ^= 0x01; + // Moving a byte across the key id and public key boundary must also change the payload, which + // the length prefixes in the transcript guarantee. + let mut extended_key_id = response.key_id.clone(); + extended_key_id.push(response.public_key[0]); + let tampered_genesis = Word::try_from([9u64, 9, 9, 9]).unwrap(); + // Injecting a scheduled rotation into a response attested without one must also break the + // signature. + let injected_next_key = NextEncryptionKeyInfo { + scheme, + key_id: response.key_id.clone(), + public_key: response.public_key.clone(), + rotation_block_num: 100, + }; + + let tampered_commitments = [ + attestation_commitment(scheme + 1, &response.key_id, genesis, &response.public_key, None), + attestation_commitment(scheme, &tampered_key_id, genesis, &response.public_key, None), + attestation_commitment(scheme, &extended_key_id, genesis, &response.public_key[1..], None), + attestation_commitment(scheme, &response.key_id, genesis, &tampered_public_key, None), + attestation_commitment( + scheme, + &response.key_id, + tampered_genesis, + &response.public_key, + None, + ), + attestation_commitment( + scheme, + &response.key_id, + genesis, + &response.public_key, + Some(&injected_next_key), + ), + ]; + for commitment in tampered_commitments { + assert!( + !signature.verify(commitment, &signing_key), + "attestation must not verify over tampered fields", + ); + } +} + +/// 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. +#[tokio::test] +async fn response_key_seals_for_the_validator_set() { + use miden_protocol::crypto::dsa::eddsa_25519_sha512::PublicKey as EncryptionPublicKey; + use miden_protocol::crypto::ies::SealingKey; + + let tv = TestValidator::new().await; + let response = tv.call_get_transaction_encryption_key().await; + + let public_key = EncryptionPublicKey::read_from_bytes(&response.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(); + + let sealed = sealed.to_bytes(); + let opened = test_decrypter() + .decrypt_transaction_inputs(&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", + ); +} + +/// Like `status`, the encryption key stays available while a backup subscription holds the +/// exclusive serve lock. +#[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()); + + drop(stream); +} diff --git a/bin/validator/src/signers/kms.rs b/bin/validator/src/signers/kms.rs index 35093dcf27..f2119fa535 100644 --- a/bin/validator/src/signers/kms.rs +++ b/bin/validator/src/signers/kms.rs @@ -1,5 +1,7 @@ +use anyhow::Context; use aws_sdk_kms::error::SdkError; use aws_sdk_kms::operation::sign::SignError; +use aws_sdk_kms::primitives::Blob; use aws_sdk_kms::types::SigningAlgorithmSpec; use miden_protocol::Word; use miden_protocol::crypto::dsa::ecdsa_k256_keccak::{PublicKey, Signature}; @@ -114,3 +116,31 @@ impl KmsSigner { self.pub_key.clone() } } + +// KMS KEY MATERIAL DECRYPTION +// ================================================================================================ + +/// Recovers key material wrapped with an AWS KMS key by calling `kms:Decrypt`. +/// +/// The ciphertext must have been produced by `kms:Encrypt` under a symmetric KMS key. The KMS key +/// ID is embedded in the ciphertext blob, so it does not need to be supplied. The caller's AWS +/// identity requires the `kms:Decrypt` permission on that key, analogous to the policy documented +/// on [`KmsSigner::new`]. +/// +/// Note that unlike [`KmsSigner`], where the private key never leaves KMS, the decrypted key +/// material is returned to and held by the calling process. +pub async fn decrypt_key_material(ciphertext: Vec) -> anyhow::Result> { + let version = aws_config::BehaviorVersion::v2026_01_12(); + let config = aws_config::load_defaults(version).await; + let client = aws_sdk_kms::Client::new(&config); + + let output = client + .decrypt() + .ciphertext_blob(Blob::new(ciphertext)) + .send() + .await + .context("KMS decrypt request failed")?; + + let plaintext = output.plaintext().context("KMS decrypt returned no plaintext")?; + Ok(plaintext.as_ref().to_vec()) +} diff --git a/bin/validator/src/signers/mod.rs b/bin/validator/src/signers/mod.rs index 59999339d7..6cb3a476e7 100644 --- a/bin/validator/src/signers/mod.rs +++ b/bin/validator/src/signers/mod.rs @@ -1,8 +1,16 @@ mod kms; -pub use kms::KmsSigner; +pub use kms::{KmsSigner, decrypt_key_material}; use miden_node_utils::spawn::spawn_blocking_in_current_span; -use miden_protocol::block::BlockHeader; +use miden_protocol::Word; use miden_protocol::crypto::dsa::ecdsa_k256_keccak::{PublicKey, Signature, SigningKey}; +use miden_protocol::crypto::dsa::eddsa_25519_sha512::{ + KeyExchangeKey, + PublicKey as EncryptionPublicKey, +}; +#[cfg(test)] +use miden_protocol::crypto::ies::SealingKey; +use miden_protocol::crypto::ies::{IesScheme, SealedMessage, UnsealingKey}; +use miden_protocol::utils::serde::{Deserializable, Serializable}; // VALIDATOR SIGNER // ================================================================================================= @@ -36,9 +44,8 @@ impl ValidatorSigner { } } - /// Signs a block header using the configured signer. - pub async fn sign(&self, header: &BlockHeader) -> anyhow::Result { - let commitment = header.commitment(); + /// Signs a commitment using the configured signer. + pub async fn sign_commitment(&self, commitment: Word) -> anyhow::Result { let signature = match self { Self::Kms(signer) => signer.sign(commitment).await?, Self::Local(signer) => spawn_blocking_in_current_span({ @@ -52,3 +59,284 @@ impl ValidatorSigner { Ok(signature) } } + +// TRANSACTION INPUT DECRYPTER +// ================================================================================================= + +/// Domain tag prefixed to the attestation payload, separating key attestations from block header +/// signatures made with the same validator key. +pub const ATTESTATION_DOMAIN: &[u8] = b"MIDEN_TX_ENCRYPTION_KEY_ATTESTATION_V1"; + +/// Decryption counterpart to [`ValidatorSigner`] for the shared transaction encryption +/// (submission) key. +/// +/// 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. +/// +/// 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; + + /// Decrypts transaction inputs sealed against the current encryption key. + /// + /// The ciphertext is a serialized [`SealedMessage`]. + async fn decrypt_transaction_inputs( + &self, + ciphertext: &[u8], + associated_data: &[u8], + ) -> anyhow::Result>; +} + +/// Public metadata of the shared transaction encryption key, in wire format. +/// +/// These are the attested fields served by the `GetTransactionEncryptionKey` endpoint. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TransactionEncryptionKeyInfo { + /// Wire identifier of the encryption scheme. + pub scheme: u32, + /// Opaque identifier of the current encryption key. + pub key_id: Vec, + /// Raw public key bytes of the shared encryption key. + pub public_key: Vec, + /// The next encryption key when a rotation is scheduled. Not populated yet; key rotation is not + /// implemented. + pub next_key: Option, +} + +/// Public metadata of the next transaction encryption key, announced ahead of a scheduled rotation, +/// in wire format. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NextEncryptionKeyInfo { + /// Wire identifier of the next key's encryption scheme. + pub scheme: u32, + /// Opaque identifier of the next encryption key. + pub key_id: Vec, + /// Raw public key bytes of the next encryption key. + pub public_key: Vec, + /// Block number at which the next key replaces the current one. + pub rotation_block_num: u32, +} + +impl TransactionEncryptionKeyInfo { + /// Returns the commitment signed by a validator to attest the encryption key. + pub fn attestation_commitment(&self, genesis_commitment: Word) -> Word { + attestation_commitment( + self.scheme, + &self.key_id, + genesis_commitment, + &self.public_key, + self.next_key.as_ref(), + ) + } +} + +/// Computes the attestation commitment over explicit wire-format fields. +/// +/// This is the single definition of the attestation payload. Verifiers (and tests) recompute the +/// commitment from response fields through this function, so any change to the payload layout +/// applies to both sides. +/// +/// Computed as the Poseidon2 hash of `ATTESTATION_DOMAIN || scheme || len(key_id) || key_id || +/// genesis_commitment || len(public_key) || public_key || next_key_transcript`, binding every +/// field of the attested response to the signature. The scheme, the rotation block number, and +/// the length prefixes are encoded as 4 bytes little-endian, and the length prefixes on the +/// variable-width fields ensure no two field combinations map to the same payload. Including the +/// genesis commitment ties the attestation to one chain, so it cannot be replayed on another +/// network whose validator reuses the same signing key. +/// +/// `next_key_transcript` is empty when no rotation is scheduled, or the next key's `scheme || +/// len(key_id) || key_id || len(public_key) || public_key || rotation_block_num` otherwise. All +/// fields ahead of it are fixed-width or length-prefixed, so the transcript's presence and +/// content are unambiguous and a scheduled rotation cannot be stripped from or injected into an +/// attested response. +pub fn attestation_commitment( + scheme: u32, + key_id: &[u8], + genesis_commitment: Word, + public_key: &[u8], + next_key: Option<&NextEncryptionKeyInfo>, +) -> Word { + let genesis_commitment = genesis_commitment.to_bytes(); + let next_key_size = next_key + .map(|next| 3 * size_of::() + next.key_id.len() + next.public_key.len()) + .unwrap_or_default(); + let mut payload = Vec::with_capacity( + ATTESTATION_DOMAIN.len() + + 3 * size_of::() + + key_id.len() + + genesis_commitment.len() + + public_key.len() + + next_key_size, + ); + payload.extend_from_slice(ATTESTATION_DOMAIN); + payload.extend_from_slice(&scheme.to_le_bytes()); + extend_with_length_prefixed(&mut payload, key_id, "key id"); + payload.extend_from_slice(&genesis_commitment); + extend_with_length_prefixed(&mut payload, public_key, "public key"); + if let Some(next) = next_key { + payload.extend_from_slice(&next.scheme.to_le_bytes()); + extend_with_length_prefixed(&mut payload, &next.key_id, "next key id"); + extend_with_length_prefixed(&mut payload, &next.public_key, "next public key"); + payload.extend_from_slice(&next.rotation_block_num.to_le_bytes()); + } + miden_protocol::Hasher::hash(&payload) +} + +/// Appends a field to the attestation payload prefixed with its length as 4 bytes little-endian. +/// +/// The length prefixes on variable-width fields keep the transcript injective: no two field +/// combinations map to the same payload. +fn extend_with_length_prefixed(payload: &mut Vec, field: &[u8], name: &str) { + let len = u32::try_from(field.len()) + .unwrap_or_else(|_| panic!("{name} length must fit in u32")) + .to_le_bytes(); + payload.extend_from_slice(&len); + payload.extend_from_slice(field); +} + +/// [`TransactionInputDecrypter`] backed by a locally provisioned X25519 shared secret. +pub struct LocalX25519TransactionInputDecrypter { + secret_key: KeyExchangeKey, +} + +impl LocalX25519TransactionInputDecrypter { + /// The IES scheme used for transaction input encryption. + pub const SCHEME: IesScheme = IesScheme::X25519XChaCha20Poly1305; + + /// Constructs a decrypter from a locally provisioned shared secret. + pub fn new(secret_key: KeyExchangeKey) -> Self { + Self { secret_key } + } + + /// Returns the wire representation of [`Self::SCHEME`]. + pub fn scheme_id() -> u32 { + u32::from(u8::from(Self::SCHEME)) + } + + /// Returns the public key of the shared encryption key. + pub fn public_key(&self) -> EncryptionPublicKey { + self.secret_key.public_key() + } + + /// Returns the opaque identifier of the current encryption key: the first 4 bytes of the public + /// key commitment. + pub fn key_id(&self) -> Vec { + self.public_key().to_commitment().to_bytes()[..4].to_vec() + } + + /// 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()) + } +} + +#[tonic::async_trait] +impl TransactionInputDecrypter for LocalX25519TransactionInputDecrypter { + async fn encryption_key(&self) -> anyhow::Result { + Ok(TransactionEncryptionKeyInfo { + scheme: Self::scheme_id(), + key_id: self.key_id(), + public_key: self.public_key().to_bytes(), + next_key: None, + }) + } + + async fn decrypt_transaction_inputs( + &self, + ciphertext: &[u8], + associated_data: &[u8], + ) -> anyhow::Result> { + use anyhow::Context; + + let message = SealedMessage::read_from_bytes(ciphertext) + .context("failed to deserialize the sealed message")?; + UnsealingKey::X25519XChaCha20Poly1305(self.secret_key.clone()) + .unseal_bytes_with_associated_data(message, associated_data) + .context("failed to unseal the transaction inputs") + } +} + +// 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()) + } + + /// Loading the same shared secret must yield the same key metadata and attestation commitment + /// on every validator instance. + #[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(); + + assert_eq!(info_a, info_b); + assert_eq!(info_a.attestation_commitment(genesis), info_b.attestation_commitment(genesis)); + } + + /// 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(); + + 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)); + } + + /// 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"; + + 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); + + // Mismatched associated data must fail authentication. + assert!( + decrypter + .decrypt_transaction_inputs(&sealed, b"wrong associated data") + .await + .is_err() + ); + + // 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()); + + // Garbage ciphertext must fail to deserialize. + assert!( + decrypter + .decrypt_transaction_inputs(b"not a sealed message", associated_data) + .await + .is_err() + ); + } +} diff --git a/crates/rpc/src/server/api.rs b/crates/rpc/src/server/api.rs index a45de8d94d..8e68600bd6 100644 --- a/crates/rpc/src/server/api.rs +++ b/crates/rpc/src/server/api.rs @@ -45,6 +45,7 @@ mod get_limits; mod get_network_note_status; mod get_note_script_by_root; mod get_notes_by_id; +mod get_transaction_encryption_key; mod status; mod submit_auth_tx; mod submit_auth_tx_batch; diff --git a/crates/rpc/src/server/api/get_transaction_encryption_key.rs b/crates/rpc/src/server/api/get_transaction_encryption_key.rs new file mode 100644 index 0000000000..2c85f94af5 --- /dev/null +++ b/crates/rpc/src/server/api/get_transaction_encryption_key.rs @@ -0,0 +1,59 @@ +use miden_node_proto::generated as proto; +use miden_node_utils::tracing::miden_instrument; +use tracing::debug; + +use super::{Request, RpcMode, RpcService}; +use crate::{COMPONENT, LOG_TARGET}; + +#[tonic::async_trait] +impl proto::server::rpc_api::GetTransactionEncryptionKey for RpcService { + type Input = (); + type Output = proto::transaction::TransactionEncryptionKey; + + fn decode(request: ()) -> tonic::Result { + Ok(request) + } + + fn encode(output: Self::Output) -> tonic::Result { + Ok(output) + } + + #[miden_instrument( + target = COMPONENT, + name = "get_transaction_encryption_key", + skip_all, + err, + )] + async fn handle( + &self, + _input: Self::Input, + metadata: &tonic::metadata::MetadataMap, + _extensions: &tonic::codegen::http::Extensions, + ) -> tonic::Result { + let original_accept_header = metadata.get(http::header::ACCEPT.as_str()).cloned(); + + debug!(target: LOG_TARGET, "Getting transaction encryption key"); + + let mut forwarded_request = Request::new(()); + if let Some(accept) = original_accept_header { + forwarded_request.metadata_mut().insert(http::header::ACCEPT.as_str(), accept); + } + + // Nodes connected to a validator ask for it directly, otherwise the request is forwarded. + match &self.mode { + RpcMode::Sequencer { validator, .. } + | RpcMode::FullNode { validator: Some(validator), .. } => validator + .as_ref() + .clone() + .get_transaction_encryption_key(forwarded_request) + .await + .map(tonic::Response::into_inner), + RpcMode::FullNode { source_rpc, validator: None, .. } => source_rpc + .as_ref() + .clone() + .get_transaction_encryption_key(forwarded_request) + .await + .map(tonic::Response::into_inner), + } + } +} diff --git a/crates/rpc/src/tests.rs b/crates/rpc/src/tests.rs index 7e801d20fa..af93ea1b1d 100644 --- a/crates/rpc/src/tests.rs +++ b/crates/rpc/src/tests.rs @@ -18,7 +18,7 @@ use miden_node_proto::clients::{ use miden_node_proto::generated::rpc::api_client::ApiClient as ProtoClient; use miden_node_proto::generated::rpc::api_server::Api; use miden_node_proto::generated::{self as proto}; -use miden_node_proto::server::{ntx_builder_api, rpc_api}; +use miden_node_proto::server::{ntx_builder_api, rpc_api, validator_api}; use miden_node_store::genesis::config::GenesisConfig; use miden_node_store::state::State; use miden_node_utils::clap::{GrpcOptionsExternal, StorageOptions}; @@ -617,7 +617,20 @@ async fn start_ntx_builder( (client, call_count, last_accept) } -async fn start_source_rpc(ntx_builder: NtxBuilderClient) -> (RpcClient, TestStore) { +fn dummy_client() -> T { + Builder::new(Url::parse("http://127.0.0.1:0").unwrap()) + .without_tls() + .without_timeout() + .without_metadata_version() + .without_metadata_genesis() + .with_otel_context_injection() + .connect_lazy::() +} + +async fn start_source_rpc( + ntx_builder: NtxBuilderClient, + validator: ValidatorClient, +) -> (RpcClient, TestStore) { let store = TestStore::start().await; let block_producer_dir = new_tempdir(); TestStore::bootstrap(&block_producer_dir); @@ -628,19 +641,11 @@ async fn start_source_rpc(ntx_builder: NtxBuilderClient) -> (RpcClient, TestStor let addr = listener.local_addr().expect("Failed to get source RPC address"); task::spawn(async move { - let validator_url = Url::parse("http://127.0.0.1:0").unwrap(); let block_producer = BlockProducerApi::new( block_producer_state, 0.into(), BlockProducerApiConfig::default(), ); - let validator = Builder::new(validator_url) - .without_tls() - .without_timeout() - .without_metadata_version() - .without_metadata_genesis() - .with_otel_context_injection() - .connect_lazy::(); let source_rpc = RpcService::new( store_state, RpcMode::sequencer(block_producer, validator), @@ -667,6 +672,276 @@ async fn start_source_rpc(ntx_builder: NtxBuilderClient) -> (RpcClient, TestStor (client, store) } +/// Stub validator gRPC service that serves a fixed transaction encryption key and rejects every +/// other RPC. +#[derive(Clone)] +struct FixedValidator { + encryption_key: proto::transaction::TransactionEncryptionKey, + call_count: Arc, + last_accept: Arc>>, +} + +#[tonic::async_trait] +impl validator_api::GetTransactionEncryptionKey for FixedValidator { + type Input = (); + type Output = proto::transaction::TransactionEncryptionKey; + + fn decode(request: ()) -> tonic::Result { + Ok(request) + } + + fn encode(output: Self::Output) -> tonic::Result { + Ok(output) + } + + async fn handle( + &self, + _input: Self::Input, + metadata: &MetadataMap, + _extensions: &Extensions, + ) -> tonic::Result { + self.call_count.fetch_add(1, Ordering::SeqCst); + let accept = metadata + .get(ACCEPT.as_str()) + .and_then(|value| value.to_str().ok()) + .map(str::to_string); + *self.last_accept.lock().expect("last_accept mutex should not be poisoned") = accept; + + Ok(self.encryption_key.clone()) + } +} + +#[tonic::async_trait] +impl validator_api::Status for FixedValidator { + type Input = (); + type Output = proto::validator::ValidatorStatus; + + fn decode(request: ()) -> tonic::Result { + Ok(request) + } + + fn encode(output: Self::Output) -> tonic::Result { + Ok(output) + } + + async fn handle( + &self, + _input: Self::Input, + _metadata: &MetadataMap, + _extensions: &Extensions, + ) -> tonic::Result { + Err(tonic::Status::unimplemented("not supported by the stub validator")) + } +} + +#[tonic::async_trait] +impl validator_api::SubmitProvenTransaction for FixedValidator { + type Input = (); + type Output = (); + + fn decode(_request: proto::transaction::ProvenTransaction) -> tonic::Result { + Ok(()) + } + + fn encode(output: Self::Output) -> tonic::Result<()> { + Ok(output) + } + + async fn handle( + &self, + _input: Self::Input, + _metadata: &MetadataMap, + _extensions: &Extensions, + ) -> tonic::Result { + Err(tonic::Status::unimplemented("not supported by the stub validator")) + } +} + +#[tonic::async_trait] +impl validator_api::SignBlock for FixedValidator { + type Input = (); + type Output = proto::blockchain::SignBlockResponse; + + fn decode(_request: proto::blockchain::ProposedBlock) -> tonic::Result { + Ok(()) + } + + fn encode(output: Self::Output) -> tonic::Result { + Ok(output) + } + + async fn handle( + &self, + _input: Self::Input, + _metadata: &MetadataMap, + _extensions: &Extensions, + ) -> tonic::Result { + Err(tonic::Status::unimplemented("not supported by the stub validator")) + } +} + +#[tonic::async_trait] +impl validator_api::BlockSubscription for FixedValidator { + type Input = (); + type Item = proto::validator::BlockSubscriptionResponse; + type ItemStream = tokio_stream::Empty>; + + fn decode(_request: proto::validator::BlockSubscriptionRequest) -> tonic::Result { + Ok(()) + } + + fn encode(item: Self::Item) -> tonic::Result { + Ok(item) + } + + async fn handle( + &self, + _input: Self::Input, + _metadata: &MetadataMap, + _extensions: &Extensions, + ) -> tonic::Result { + Err(tonic::Status::unimplemented("not supported by the stub validator")) + } +} + +/// 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, +) -> (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"); + let call_count = Arc::new(AtomicUsize::new(0)); + let last_accept = Arc::new(std::sync::Mutex::new(None)); + let service = FixedValidator { + encryption_key, + call_count: Arc::clone(&call_count), + last_accept: Arc::clone(&last_accept), + }; + + task::spawn(async move { + tonic::transport::Server::builder() + .add_service(validator_api::service(service)) + .serve_with_incoming(TcpListenerStream::new(listener)) + .await + .expect("Failed to serve validator"); + }); + + let client = Builder::new(Url::parse(&format!("http://{addr}")).unwrap()) + .without_tls() + .without_timeout() + .without_metadata_version() + .without_metadata_genesis() + .without_otel_context_injection() + .connect_lazy::(); + + (client, call_count, last_accept) +} + +/// 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], + 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, + }), + } +} + +#[tokio::test] +async fn full_node_with_validator_forwards_get_transaction_encryption_key() { + let expected = test_encryption_key(); + let (validator, validator_call_count, _last_accept) = start_validator(expected.clone()).await; + let local_store = TestStore::start().await; + let full_node = RpcService::new( + Arc::clone(&local_store.state), + RpcMode::full_node(dummy_client::(), 100, Some(validator), None), + None, + NonZeroUsize::new(1_000).unwrap(), + None, + ); + + let response = full_node + .get_transaction_encryption_key(Request::new(())) + .await + .expect("full-node RPC should forward the encryption key request to its validator") + .into_inner(); + + assert_eq!(response, expected); + assert_eq!(validator_call_count.load(Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn full_node_forwards_get_transaction_encryption_key_to_source_rpc() { + let expected = test_encryption_key(); + let (validator, validator_call_count, _last_accept) = start_validator(expected.clone()).await; + let (source_rpc, _source_store) = + start_source_rpc(dummy_client::(), validator).await; + let local_store = TestStore::start().await; + let full_node = RpcService::new( + Arc::clone(&local_store.state), + RpcMode::full_node(source_rpc, 100, None, None), + None, + NonZeroUsize::new(1_000).unwrap(), + None, + ); + + let response = full_node + .get_transaction_encryption_key(Request::new(())) + .await + .expect("full-node RPC should forward the encryption key request to its source") + .into_inner(); + + assert_eq!(response, expected); + assert_eq!(validator_call_count.load(Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn full_node_preserves_original_accept_metadata_when_forwarding_encryption_key() { + let expected = test_encryption_key(); + let (validator, _validator_call_count, last_accept) = start_validator(expected.clone()).await; + let (source_rpc, source_store) = + start_source_rpc(dummy_client::(), validator).await; + let local_store = TestStore::start().await; + let full_node = RpcService::new( + Arc::clone(&local_store.state), + RpcMode::full_node(source_rpc, 100, None, None), + None, + NonZeroUsize::new(1_000).unwrap(), + None, + ); + + let original_accept = format!( + "application/vnd.miden; version={}; genesis={}", + env!("CARGO_PKG_VERSION"), + source_store.genesis_commitment().to_hex(), + ); + let mut request = Request::new(()); + request.metadata_mut().insert(ACCEPT.as_str(), original_accept.parse().unwrap()); + + let response = full_node + .get_transaction_encryption_key(request) + .await + .expect("full-node RPC should forward the encryption key request") + .into_inner(); + + assert_eq!(response, expected); + assert_eq!( + *last_accept.lock().expect("last_accept mutex should not be poisoned"), + Some(original_accept), + ); +} + #[tokio::test] async fn full_node_forwards_get_network_note_status_to_source_rpc() { let expected = proto::rpc::GetNetworkNoteStatusResponse { @@ -677,7 +952,8 @@ async fn full_node_forwards_get_network_note_status_to_source_rpc() { }; let (ntx_builder, ntx_builder_call_count, _last_accept) = start_ntx_builder(expected.clone()).await; - let (source_rpc, _source_store) = start_source_rpc(ntx_builder).await; + let (source_rpc, _source_store) = + start_source_rpc(ntx_builder, dummy_client::()).await; let local_store = TestStore::start().await; let full_node = RpcService::new( Arc::clone(&local_store.state), @@ -707,7 +983,8 @@ async fn full_node_preserves_original_accept_metadata_when_forwarding() { }; let (ntx_builder, _ntx_builder_call_count, last_accept) = start_ntx_builder(expected.clone()).await; - let (source_rpc, source_store) = start_source_rpc(ntx_builder).await; + let (source_rpc, source_store) = + start_source_rpc(ntx_builder, dummy_client::()).await; let local_store = TestStore::start().await; let full_node = RpcService::new( Arc::clone(&local_store.state), diff --git a/docs/external/src/full-node/rpc.md b/docs/external/src/full-node/rpc.md index b16561cbe9..d2642dbd72 100644 --- a/docs/external/src/full-node/rpc.md +++ b/docs/external/src/full-node/rpc.md @@ -13,8 +13,10 @@ public RPC capacity. Read queries are served from the full node's local state. This includes account, block, note, and sync methods. -The network-note debugging endpoint, `GetNetworkNoteStatus`, is the exception: it depends on NTX builder state rather -than replicated chain state, so full nodes forward it to the configured upstream RPC source. +Two endpoints are exceptions because they depend on state that is not replicated. The network-note debugging endpoint, +`GetNetworkNoteStatus`, depends on NTX builder state, so full nodes forward it to the configured upstream RPC source. +`GetTransactionEncryptionKey` depends on a validator: full nodes with a validator connected forward it there, and full +nodes without one forward it to the upstream RPC source. ## Transaction Submission diff --git a/docs/external/src/network-operator/bootstrap-and-genesis.md b/docs/external/src/network-operator/bootstrap-and-genesis.md index 584dc65824..cff75a3ee3 100644 --- a/docs/external/src/network-operator/bootstrap-and-genesis.md +++ b/docs/external/src/network-operator/bootstrap-and-genesis.md @@ -36,7 +36,7 @@ miden-validator bootstrap \ --genesis-block-directory genesis-data \ --accounts-directory accounts \ --genesis-config-file genesis.toml \ - --key.kms-id + --signing-key.kms-id ``` Upload `genesis-data/genesis.dat` so it is served at: @@ -77,7 +77,7 @@ miden-validator bootstrap \ --genesis-block-directory genesis-data \ --accounts-directory accounts \ --genesis-config-file genesis.toml \ - --key.hex + --signing-key.hex ``` For unofficial networks or pre-publication testing, distribute the signed genesis block file directly and initialize diff --git a/docs/external/src/network-operator/validator.md b/docs/external/src/network-operator/validator.md index 63e1fe7b13..29032750b2 100644 --- a/docs/external/src/network-operator/validator.md +++ b/docs/external/src/network-operator/validator.md @@ -38,4 +38,15 @@ miden-validator start \ For local development, the validator can use its default insecure development key. Production deployments should configure validator signing explicitly, either with a local key or with KMS-backed signing. +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. + +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. + Use `miden-validator start --help` for the complete current option list. diff --git a/docs/external/src/rpc/index.md b/docs/external/src/rpc/index.md index b8f97ccbbd..0da68d6a8a 100644 --- a/docs/external/src/rpc/index.md +++ b/docs/external/src/rpc/index.md @@ -50,7 +50,7 @@ The RPC server supports: | ---------------------- | --------------------------------------------------------------------------------------------------------------- | | Status and limits | `Status`, `GetLimits` | | State queries | `GetAccount`, `GetBlockByNumber`, `GetBlockHeaderByNumber`, `GetNotesById`, `GetNoteScriptByRoot` | -| Transaction submission | `SubmitProvenTx`, `SubmitProvenTxBatch` | +| Transaction submission | `GetTransactionEncryptionKey`, `SubmitProvenTx`, `SubmitProvenTxBatch` | | State synchronization | `SyncTransactions`, `SyncNotes`, `SyncNullifiers`, `SyncAccountVault`, `SyncAccountStorageMaps`, `SyncChainMmr` | | Block streaming | `BlockSubscription`, `ProofSubscription` | | Network note debugging | `GetNetworkNoteStatus` | diff --git a/docs/external/src/rpc/public-api.md b/docs/external/src/rpc/public-api.md index efd9ab7b21..5e92b9b5e3 100644 --- a/docs/external/src/rpc/public-api.md +++ b/docs/external/src/rpc/public-api.md @@ -32,10 +32,20 @@ grpcurl rpc.testnet.miden.io:443 describe rpc.Api ## Transaction Submission -| Method | Purpose | -| --------------------- | ------------------------------------------------------------------------------------------- | -| `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 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. | + +The public key returned by `GetTransactionEncryptionKey` is shared across the whole validator set, while each +attestation is specific to one validator (currently the response carries a single attestation). Clients verify an +attestation against a validator signing key they already trust from the chain and reconstruct the encryption key with +miden-crypto. The exact attestation payload is documented on the `TransactionEncryptionKey` proto message. Note that +this scheme does not hide transaction inputs from holders of the shared encryption secret (currently the network +operator and every validator) and provides no forward secrecy. The attestation proves which validator vouched for the +key but does not prove freshness: after a key rotation, a replayed older signed key still verifies until a chain or +epoch rule for freshness exists. 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 336400ae04..5e8f5ed3e8 100644 --- a/docs/internal/src/validator.md +++ b/docs/internal/src/validator.md @@ -25,3 +25,22 @@ The validator ensures that each new block is sequential with the previously sign It also checks that the block contains only transactions that it has previously seen and verified. 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 will 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. + +This scheme does not protect the inputs from parties holding the shared secret and has no forward +secrecy. It is the first phase of the transaction input encryption design: later phases move the +key material to threshold and TEE-managed setups. diff --git a/proto/proto/internal/validator.proto b/proto/proto/internal/validator.proto index 9d6c5c98f8..c7d7d09b18 100644 --- a/proto/proto/internal/validator.proto +++ b/proto/proto/internal/validator.proto @@ -27,6 +27,14 @@ service Api { // block subscription, the blocks streamed here carry no proofs; they must be commissioned // separately as part of recovery. rpc BlockSubscription(BlockSubscriptionRequest) returns (stream BlockSubscriptionResponse) {} + + // 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) {} } // BLOCK SUBSCRIPTION diff --git a/proto/proto/rpc.proto b/proto/proto/rpc.proto index bd44df4484..3217b28b80 100644 --- a/proto/proto/rpc.proto +++ b/proto/proto/rpc.proto @@ -43,6 +43,16 @@ service Api { // TRANSACTION SUBMISSION ENDPOINTS // -------------------------------------------------------------------------------------------- + // 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) {} + // 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 eeb358f5ac..b4e6d5c782 100644 --- a/proto/proto/types/transaction.proto +++ b/proto/proto/types/transaction.proto @@ -62,6 +62,91 @@ message TransactionBatch { repeated bytes transaction_inputs = 3; } +// IES scheme used for transaction input encryption. +// +// Non-zero values match the discriminants of [miden_protocol::crypto::ies::IesScheme]. New +// schemes are added here as the node starts supporting them. +enum IesScheme { + IES_SCHEME_UNSPECIFIED = 0; + IES_SCHEME_X25519_XCHACHA20_POLY1305 = 1; +} + +// A single validator's attestation of the transaction encryption key. +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 + // validator signing key committed in block headers to be trusted. + 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_validator::attestation_commitment`. + 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. +// +// 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. +message TransactionEncryptionKey { + // IES scheme the encryption key belongs to. + // + // Currently always `IES_SCHEME_X25519_XCHACHA20_POLY1305`. + IesScheme scheme = 1; + + // Opaque identifier of the current encryption key. Changes when the key rotates. + bytes key_id = 2; + + // Raw public key bytes of the shared encryption key. + // + // For the X25519 scheme these are the 32 bytes produced by + // [miden_protocol::crypto::dsa::eddsa_25519_sha512::PublicKey::to_bytes] (an Ed25519 public + // 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; + + // 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; + + // 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; +} + // Represents a transaction ID. message TransactionId { // The transaction ID. diff --git a/scripts/run-node.sh b/scripts/run-node.sh index 0c7da62141..85c75d2134 100755 --- a/scripts/run-node.sh +++ b/scripts/run-node.sh @@ -110,7 +110,7 @@ if [[ "$SKIP_BOOTSTRAP" != "true" ]]; then echo "Bootstrapping validator..." KMS_BOOTSTRAP_ARGS=() if [[ -n "$KMS_KEY_ID" ]]; then - KMS_BOOTSTRAP_ARGS+=(--key.kms-id "$KMS_KEY_ID") + KMS_BOOTSTRAP_ARGS+=(--signing-key.kms-id "$KMS_KEY_ID") fi "$VALIDATOR_BINARY" bootstrap \ @@ -137,7 +137,7 @@ echo "=== Starting components ===" KMS_START_ARGS=() if [[ -n "$KMS_KEY_ID" ]]; then - KMS_START_ARGS+=(--key.kms-id "$KMS_KEY_ID") + KMS_START_ARGS+=(--signing-key.kms-id "$KMS_KEY_ID") fi echo "Starting validator..."