From 781d918ceb506c526de6be58ddb6af84ae665083 Mon Sep 17 00:00:00 2001 From: Juan Munoz Date: Tue, 14 Jul 2026 18:42:01 -0300 Subject: [PATCH 1/7] feat: add input encryption for validators --- CHANGELOG.md | 1 + Cargo.lock | 1 + bin/validator/Cargo.toml | 1 + bin/validator/src/commands/mod.rs | 77 +++-- bin/validator/src/commands/start.rs | 4 +- bin/validator/src/lib.rs | 2 +- bin/validator/src/server/mod.rs | 6 +- .../get_transaction_encryption_key.rs | 42 +++ .../src/server/validator_service/mod.rs | 27 +- .../src/server/validator_service/tests.rs | 202 +++++++++++- bin/validator/src/signers/mod.rs | 172 ++++++++++- crates/rpc/src/server/api.rs | 1 + .../api/get_transaction_encryption_key.rs | 58 ++++ crates/rpc/src/tests.rs | 292 +++++++++++++++++- docs/external/src/full-node/rpc.md | 6 +- .../src/network-operator/validator.md | 5 + docs/external/src/rpc/index.md | 2 +- docs/external/src/rpc/public-api.md | 16 +- docs/internal/src/validator.md | 20 ++ proto/proto/internal/validator.proto | 8 + proto/proto/rpc.proto | 8 + proto/proto/types/transaction.proto | 35 +++ 22 files changed, 933 insertions(+), 53 deletions(-) create mode 100644 bin/validator/src/server/validator_service/get_transaction_encryption_key.rs create mode 100644 crates/rpc/src/server/api/get_transaction_encryption_key.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 39e6457240..07f89383a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased - [BREAKING] Updated `miden-protocol` dependencies to use the `next` branch (v0.16). Block and transaction account updates now use the absolute `AccountPatch` representation instead of the relative `AccountDelta`, and the `miden-tx-batch-prover` crate was renamed to `miden-tx-batch` ([#2282](https://github.com/0xMiden/node/pull/2282)). +- Added the `GetTransactionEncryptionKey` endpoint to the validator and RPC APIs, returning the shared transaction encryption key attested by the serving validator's signing key. The shared secret is configured on the validator via `--encryption-key.hex`. `MIDEN_VALIDATOR_ENCRYPTION_KEY` and must be identical across the validator set ([#2319](https://github.com/0xMiden/node/issues/2319)). ## v0.15.0 (2026-06-10) diff --git a/Cargo.lock b/Cargo.lock index 0fc736ccfa..617234fdbd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3795,6 +3795,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..da457ed4b8 100644 --- a/bin/validator/Cargo.toml +++ b/bin/validator/Cargo.toml @@ -47,5 +47,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/mod.rs b/bin/validator/src/commands/mod.rs index ef76d9ce6b..4670683f41 100644 --- a/bin/validator/src/commands/mod.rs +++ b/bin/validator/src/commands/mod.rs @@ -10,13 +10,15 @@ 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, ValidatorEncryptor, 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_ENCRYPTION_KEY: &str = "MIDEN_VALIDATOR_ENCRYPTION_KEY"; const ENV_GENESIS_CONFIG_FILE: &str = "MIDEN_VALIDATOR_GENESIS_CONFIG_FILE"; const ENV_SQLITE_CONNECTION_POOL_SIZE: &str = "MIDEN_VALIDATOR_SQLITE_CONNECTION_POOL_SIZE"; @@ -24,6 +26,10 @@ const ENV_SQLITE_CONNECTION_POOL_SIZE: &str = "MIDEN_VALIDATOR_SQLITE_CONNECTION pub(crate) const INSECURE_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 // ================================================================================================ @@ -116,6 +122,20 @@ pub enum ValidatorCommand { group = "key" )] kms_key_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. + #[arg( + long = "encryption-key.hex", + env = ENV_ENCRYPTION_KEY, + value_name = "VALIDATOR_ENCRYPTION_KEY", + default_value = INSECURE_ENCRYPTION_KEY_HEX + )] + encryption_key: String, }, } @@ -154,34 +174,45 @@ impl ValidatorCommand { data_directory, kms_key_id, sqlite_connection_pool_size, + encryption_key, .. } => { 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 + // 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 for production deployments" + ); + } + + let encryption_key_bytes = 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 encryptor = ValidatorEncryptor::new_local(encryption_key); + + let signer = if let Some(kms_key_id) = kms_key_id { + ValidatorSigner::new_kms(kms_key_id).await? } 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 - } + ValidatorSigner::new_local(signer) + }; + + start::start( + address, + grpc_options, + signer, + encryptor, + data_directory, + sqlite_connection_pool_size, + shutdown, + ) + .await }, } } diff --git a/bin/validator/src/commands/start.rs b/bin/validator/src/commands/start.rs index 62adecf64f..068290c216 100644 --- a/bin/validator/src/commands/start.rs +++ b/bin/validator/src/commands/start.rs @@ -5,13 +5,14 @@ use std::path::PathBuf; 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, ValidatorEncryptor, ValidatorServer, ValidatorSigner}; // Starts the validator component. pub async fn start( address: SocketAddr, grpc_options: GrpcOptionsInternal, signer: ValidatorSigner, + encryptor: ValidatorEncryptor, data_directory: PathBuf, sqlite_connection_pool_size: NonZeroUsize, shutdown: CancellationToken, @@ -22,6 +23,7 @@ pub async fn start( address, grpc_options, signer, + encryptor, data_directory, sqlite_connection_pool_size, } diff --git a/bin/validator/src/lib.rs b/bin/validator/src/lib.rs index cf0dd7c9fa..41e7d6a1fe 100644 --- a/bin/validator/src/lib.rs +++ b/bin/validator/src/lib.rs @@ -6,7 +6,7 @@ mod tx_validation; pub use data_directory::DataDirectory; pub use server::ValidatorServer; -pub use signers::{KmsSigner, ValidatorSigner}; +pub use signers::{KmsSigner, ValidatorEncryptor, ValidatorSigner}; // CONSTANTS // ================================================================================================= diff --git a/bin/validator/src/server/mod.rs b/bin/validator/src/server/mod.rs index 139e2d9517..a7540b139b 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, ValidatorEncryptor, ValidatorSigner}; mod validator_service; @@ -43,6 +43,9 @@ pub struct ValidatorServer { /// The signer used to sign blocks. pub signer: ValidatorSigner, + /// The shared transaction encryption key used to unseal encrypted transaction inputs. + pub encryptor: ValidatorEncryptor, + /// The data directory for the validator component's database files. pub data_directory: DataDirectory, @@ -98,6 +101,7 @@ impl ValidatorServer { .add_service(validator_api::service( ValidatorService::new( self.signer, + self.encryptor, 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..9b5657fb88 --- /dev/null +++ b/bin/validator/src/server/validator_service/get_transaction_encryption_key.rs @@ -0,0 +1,42 @@ +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, ValidatorEncryptor}; + +#[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: u32::from(u8::from(ValidatorEncryptor::SCHEME)), + key_id: self.encryptor.key_id(), + public_key: self.encryptor.public_key().to_bytes(), + signature: self.encryption_key_attestation.to_bytes(), + }) + } +} diff --git a/bin/validator/src/server/validator_service/mod.rs b/bin/validator/src/server/validator_service/mod.rs index 9621666945..3ab5e1763c 100644 --- a/bin/validator/src/server/validator_service/mod.rs +++ b/bin/validator/src/server/validator_service/mod.rs @@ -19,12 +19,13 @@ 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::{COMPONENT, ValidatorEncryptor, ValidatorSigner}; #[cfg(test)] mod tests; mod block_subscription; +mod get_transaction_encryption_key; mod sign_block; mod status; mod submit_proven_transaction; @@ -61,6 +62,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 +76,10 @@ pub enum ValidatorError { /// Implements the gRPC API for the validator. pub(crate) struct ValidatorService { signer: ValidatorSigner, + encryptor: ValidatorEncryptor, + /// 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 +102,7 @@ pub(crate) struct ValidatorService { impl ValidatorService { pub(crate) async fn new( signer: ValidatorSigner, + encryptor: ValidatorEncryptor, db: Database, block_store: BlockStore, initial_chain_tip: u32, @@ -121,8 +131,23 @@ 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_attestation = signer + .sign_commitment(encryptor.attestation_commitment(genesis_commitment)) + .await + .map_err(|err| ValidatorError::EncryptionKeyAttestationFailed(err.to_string()))?; + Ok(Self { signer, + encryptor, + encryption_key_attestation, serve_lock: Arc::new(tokio::sync::RwLock::new(())), db: db.into(), block_store, diff --git a/bin/validator/src/server/validator_service/tests.rs b/bin/validator/src/server/validator_service/tests.rs index 99c9cfe2ab..10add23536 100644 --- a/bin/validator/src/server/validator_service/tests.rs +++ b/bin/validator/src/server/validator_service/tests.rs @@ -4,20 +4,32 @@ use miden_node_proto::generated::{self as proto}; use miden_node_proto::server::validator_api; use miden_node_store::{BlockStore, GenesisState}; use miden_node_utils::fee::test_fee_params; -use miden_protocol::Word; use miden_protocol::block::{BlockHeader, BlockInputs, ProposedBlock}; -use miden_protocol::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_protocol::{Hasher, Word}; +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::{ValidatorEncryptor, ValidatorSigner}; // TEST HELPERS // ================================================================================================ +/// The shared transaction encryption secret provisioned to every test validator. +const TEST_ENCRYPTION_SECRET: [u8; 32] = [3u8; 32]; + +/// Creates a [`ValidatorEncryptor`] from the shared test secret, modelling the identically +/// provisioned encryption key of a validator in the set. +fn test_encryptor() -> ValidatorEncryptor { + let key = KeyExchangeKey::read_from_bytes(&TEST_ENCRYPTION_SECRET) + .expect("test secret should be a valid key exchange key"); + ValidatorEncryptor::new_local(key) +} + /// Test harness that wraps a [`Validator`] and tracks the chain MMR state needed to construct valid /// [`ProposedBlock`]s. struct TestValidator { @@ -38,7 +50,9 @@ 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, test_encryptor(), db, block_store, 0, 0, 0) + .await + .unwrap(), chain: PartialBlockchain::default(), chain_tip: genesis_header, _temp_dir: temp_dir, @@ -92,6 +106,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 +206,8 @@ 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, test_encryptor(), db, block_store, 0, 0, 0).await; assert!( matches!(result, Err(ValidatorError::ValidatorKeyMismatch { .. })), "expected ValidatorKeyMismatch error", @@ -690,3 +714,169 @@ async fn requests_run_concurrently() { drop(first); drop(second); } + +// TRANSACTION ENCRYPTION KEY +// ================================================================================================ + +/// Recomputes the attestation commitment from response fields and the chain's genesis commitment. +fn attestation_commitment_of( + scheme: u32, + key_id: u32, + genesis_commitment: Word, + public_key: &[u8], +) -> Word { + let genesis_commitment = genesis_commitment.to_bytes(); + let mut payload = Vec::with_capacity( + ValidatorEncryptor::ATTESTATION_DOMAIN.len() + + 2 * size_of::() + + genesis_commitment.len() + + public_key.len(), + ); + payload.extend_from_slice(ValidatorEncryptor::ATTESTATION_DOMAIN); + payload.extend_from_slice(&scheme.to_le_bytes()); + payload.extend_from_slice(&key_id.to_le_bytes()); + payload.extend_from_slice(&genesis_commitment); + payload.extend_from_slice(public_key); + Hasher::hash(&payload) +} + +/// 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 encryptor = test_encryptor(); + assert_eq!(response.scheme, u32::from(u8::from(ValidatorEncryptor::SCHEME))); + assert_eq!(response.key_id, encryptor.key_id()); + assert_eq!(response.public_key, encryptor.public_key().to_bytes()); + + let commitment = + attestation_commitment_of(response.scheme, response.key_id, genesis, &response.public_key); + assert_eq!(commitment, encryptor.attestation_commitment(genesis)); + + let signature = + Signature::read_from_bytes(&response.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.signature, response_b.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.signature).unwrap(); + let signing_key = tv.server.signer.public_key(); + + let mut tampered_public_key = response.public_key.clone(); + tampered_public_key[0] ^= 0x01; + let tampered_genesis = Word::try_from([9u64, 9, 9, 9]).unwrap(); + + let tampered_commitments = [ + attestation_commitment_of( + response.scheme + 1, + response.key_id, + genesis, + &response.public_key, + ), + attestation_commitment_of( + response.scheme, + response.key_id.wrapping_add(1), + genesis, + &response.public_key, + ), + attestation_commitment_of(response.scheme, response.key_id, genesis, &tampered_public_key), + attestation_commitment_of( + response.scheme, + response.key_id, + tampered_genesis, + &response.public_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 opened = test_encryptor() + .unseal_bytes_with_associated_data(sealed.clone(), associated_data) + .unwrap(); + assert_eq!(opened.as_slice(), plaintext); + + assert!( + test_encryptor() + .unseal_bytes_with_associated_data(sealed, b"other associated data") + .is_err(), + "unsealing 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/mod.rs b/bin/validator/src/signers/mod.rs index 59999339d7..869f31df09 100644 --- a/bin/validator/src/signers/mod.rs +++ b/bin/validator/src/signers/mod.rs @@ -1,8 +1,15 @@ mod kms; pub use kms::KmsSigner; use miden_node_utils::spawn::spawn_blocking_in_current_span; +use miden_protocol::Word; use miden_protocol::block::BlockHeader; use miden_protocol::crypto::dsa::ecdsa_k256_keccak::{PublicKey, Signature, SigningKey}; +use miden_protocol::crypto::dsa::eddsa_25519_sha512::{ + KeyExchangeKey, + PublicKey as EncryptionPublicKey, +}; +use miden_protocol::crypto::ies::{IesError, IesScheme, SealedMessage, SealingKey, UnsealingKey}; +use miden_protocol::utils::serde::Serializable; // VALIDATOR SIGNER // ================================================================================================= @@ -38,7 +45,11 @@ impl ValidatorSigner { /// Signs a block header using the configured signer. pub async fn sign(&self, header: &BlockHeader) -> anyhow::Result { - let commitment = header.commitment(); + self.sign_commitment(header.commitment()).await + } + + /// 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 +63,162 @@ impl ValidatorSigner { Ok(signature) } } + +// VALIDATOR ENCRYPTOR +// ================================================================================================= + +/// Encryption-key counterpart to [`ValidatorSigner`], wrapping the shared transaction encryption +/// (submission) key. +/// +/// Unlike the signing key, the secret material behind this type 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. +pub enum ValidatorEncryptor { + Local(KeyExchangeKey), +} + +impl ValidatorEncryptor { + /// The IES scheme used for transaction input encryption. + pub const SCHEME: IesScheme = IesScheme::X25519XChaCha20Poly1305; + + /// 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"; + + /// Constructs an encryptor from a locally provisioned shared secret. + pub fn new_local(secret_key: KeyExchangeKey) -> Self { + Self::Local(secret_key) + } + + /// Returns the public key of the shared encryption key. + pub fn public_key(&self) -> EncryptionPublicKey { + match self { + Self::Local(key) => key.public_key(), + } + } + + /// Returns the opaque identifier of the current encryption key. + pub fn key_id(&self) -> u32 { + let commitment = self.public_key().to_commitment().to_bytes(); + u32::from_le_bytes(commitment[..4].try_into().expect("commitment is at least 4 bytes")) + } + + /// Returns the sealing key that clients use to encrypt messages to the validator set. + pub fn sealing_key(&self) -> SealingKey { + SealingKey::X25519XChaCha20Poly1305(self.public_key()) + } + + /// Returns the commitment signed by a validator to attest the encryption key. + /// + /// Computed as the Poseidon2 hash of + /// `ATTESTATION_DOMAIN || scheme || key_id || genesis_commitment || public_key`, binding + /// every field of the attested response to the signature. The scheme and key id are bound at + /// their full wire width (4 bytes little-endian each) so no wire value maps to another + /// 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. + pub fn attestation_commitment(&self, genesis_commitment: Word) -> Word { + let scheme = u32::from(u8::from(Self::SCHEME)); + let genesis_commitment = genesis_commitment.to_bytes(); + let public_key = self.public_key().to_bytes(); + let mut payload = Vec::with_capacity( + Self::ATTESTATION_DOMAIN.len() + + 2 * size_of::() + + genesis_commitment.len() + + public_key.len(), + ); + payload.extend_from_slice(Self::ATTESTATION_DOMAIN); + payload.extend_from_slice(&scheme.to_le_bytes()); + payload.extend_from_slice(&self.key_id().to_le_bytes()); + payload.extend_from_slice(&genesis_commitment); + payload.extend_from_slice(&public_key); + miden_protocol::Hasher::hash(&payload) + } + + /// Unseals a message encrypted against the shared encryption key. + pub fn unseal_bytes_with_associated_data( + &self, + message: SealedMessage, + associated_data: &[u8], + ) -> Result, IesError> { + match self { + Self::Local(key) => UnsealingKey::X25519XChaCha20Poly1305(key.clone()) + .unseal_bytes_with_associated_data(message, associated_data), + } + } +} + +#[cfg(test)] +mod tests { + use miden_protocol::utils::serde::Deserializable; + use rand::rng; + + use super::*; + + /// Loading the same shared secret must yield the same public key, key id, and attestation + /// commitment on every validator instance. + #[test] + fn same_secret_yields_same_public_material() { + let secret = [7u8; 32]; + let genesis = Word::try_from([1u64, 2, 3, 4]).unwrap(); + let key_a = KeyExchangeKey::read_from_bytes(&secret).unwrap(); + let key_b = KeyExchangeKey::read_from_bytes(&secret).unwrap(); + let encryptor_a = ValidatorEncryptor::new_local(key_a); + let encryptor_b = ValidatorEncryptor::new_local(key_b); + + assert_eq!(encryptor_a.public_key(), encryptor_b.public_key()); + assert_eq!(encryptor_a.key_id(), encryptor_b.key_id()); + assert_eq!( + encryptor_a.attestation_commitment(genesis), + encryptor_b.attestation_commitment(genesis) + ); + } + + /// Different secrets must yield different public keys and key ids. + #[test] + fn different_secrets_yield_different_public_material() { + let genesis = Word::try_from([1u64, 2, 3, 4]).unwrap(); + let key_a = KeyExchangeKey::read_from_bytes(&[7u8; 32]).unwrap(); + let key_b = KeyExchangeKey::read_from_bytes(&[8u8; 32]).unwrap(); + let encryptor_a = ValidatorEncryptor::new_local(key_a); + let encryptor_b = ValidatorEncryptor::new_local(key_b); + + assert_ne!(encryptor_a.public_key(), encryptor_b.public_key()); + assert_ne!(encryptor_a.key_id(), encryptor_b.key_id()); + assert_ne!( + encryptor_a.attestation_commitment(genesis), + encryptor_b.attestation_commitment(genesis) + ); + } + + /// A message sealed against the encryptor's sealing key must unseal to the original plaintext, + /// and unsealing must reject a mismatched associated data or a mismatched key. + #[test] + fn seal_unseal_roundtrip() { + let mut rng = rng(); + let encryptor = + ValidatorEncryptor::new_local(KeyExchangeKey::read_from_bytes(&[7u8; 32]).unwrap()); + let plaintext = b"transaction inputs"; + let associated_data = b"scheme|key_id|chain|tx"; + + let sealed = encryptor + .sealing_key() + .seal_bytes_with_associated_data(&mut rng, plaintext, associated_data) + .unwrap(); + let opened = encryptor + .unseal_bytes_with_associated_data(sealed.clone(), associated_data) + .unwrap(); + assert_eq!(opened.as_slice(), plaintext); + + // Mismatched associated data must fail authentication. + assert!( + encryptor + .unseal_bytes_with_associated_data(sealed.clone(), b"wrong associated data") + .is_err() + ); + + // A different shared secret must fail to unseal. + let other = + ValidatorEncryptor::new_local(KeyExchangeKey::read_from_bytes(&[8u8; 32]).unwrap()); + assert!(other.unseal_bytes_with_associated_data(sealed, associated_data).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..7d80a651e1 --- /dev/null +++ b/crates/rpc/src/server/api/get_transaction_encryption_key.rs @@ -0,0 +1,58 @@ +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); + } + + 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 ac8df3c171..a3c4f9ad9c 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}; @@ -572,7 +572,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); @@ -583,19 +596,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), @@ -622,6 +627,267 @@ 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: 1, + key_id: 0xDEAD_BEEF, + public_key: vec![7; 32], + signature: vec![9; 65], + } +} + +#[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 { @@ -632,7 +898,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), @@ -662,7 +929,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/validator.md b/docs/external/src/network-operator/validator.md index 63e1fe7b13..9ae804e619 100644 --- a/docs/external/src/network-operator/validator.md +++ b/docs/external/src/network-operator/validator.md @@ -38,4 +38,9 @@ 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. Production deployments should source it from a secrets manager. The validator logs a warning +at startup if the insecure development default is in use. + 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..1c2536284a 100644 --- a/docs/external/src/rpc/public-api.md +++ b/docs/external/src/rpc/public-api.md @@ -32,10 +32,18 @@ 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 shared 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. | + +`GetTransactionEncryptionKey` is forwarded to a validator and passed through unchanged: the public key is shared across +the whole validator set, while the attesting signature is specific to the serving validator. Clients verify the +signature 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. 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..4d52b3834b 100644 --- a/docs/internal/src/validator.md +++ b/docs/internal/src/validator.md @@ -25,3 +25,23 @@ 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 (submission-path encryption lands in a +follow-up change). + +The `GetTransactionEncryptionKey` endpoint returns the shared public key together with an IES +scheme identifier, an opaque key ID, and a 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..1f5bcbbfb2 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 shared transaction encryption public key, 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 attesting signature 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..c6c3750486 100644 --- a/proto/proto/rpc.proto +++ b/proto/proto/rpc.proto @@ -43,6 +43,14 @@ service Api { // TRANSACTION SUBMISSION ENDPOINTS // -------------------------------------------------------------------------------------------- + // Returns the shared transaction encryption public key, attested by the signing key of the + // validator that served the request. + // + // The request is forwarded to a validator and the response is passed through unchanged. The + // encryption key is shared across the whole validator set, while the attesting signature is + // specific to the serving validator. + 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..cc3310410d 100644 --- a/proto/proto/types/transaction.proto +++ b/proto/proto/types/transaction.proto @@ -62,6 +62,41 @@ message TransactionBatch { repeated bytes transaction_inputs = 3; } +// The shared transaction encryption key, attested by a validator. +// +// The public key is shared across the whole validator set, while the attesting signature is +// specific to the validator that served the request. Verifying the signature against a +// chain-recognized validator signing key proves the encryption key was vouched for by a +// legitimate validator. +message TransactionEncryptionKey { + // IES scheme identifier as defined by [miden_protocol::crypto::ies::IesScheme]. + // + // Currently always `1` (X25519 + XChaCha20-Poly1305). + uint32 scheme = 1; + + // Opaque identifier of the current encryption key. Changes when the key rotates. + fixed32 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; + + // The serving validator's signature over the attestation commitment: the Poseidon2 byte-mode + // hash of `domain_tag || scheme || key_id || genesis_commitment || public_key`, where + // `domain_tag` is the ASCII string `MIDEN_TX_ENCRYPTION_KEY_ATTESTATION_V1`, scheme and key_id + // are bound at their full wire width (4 bytes little-endian each), and the genesis block + // commitment ties the attestation to one network. The canonical construction is + // `miden_validator::ValidatorEncryptor::attestation_commitment`. + // + // Encoded using [miden_serde_utils::Serializable] implementation for + // [miden_protocol::crypto::dsa::ecdsa_k256_keccak::Signature]. Verifiable against the + // validator's signing key committed in block headers. + bytes signature = 4; +} + // Represents a transaction ID. message TransactionId { // The transaction ID. From 68c0c3cab2aa19ecf86f9c3cf840895e024917b3 Mon Sep 17 00:00:00 2001 From: Juan Munoz Date: Thu, 16 Jul 2026 17:18:15 -0300 Subject: [PATCH 2/7] chore: address PR comments --- CHANGELOG.md | 2 - bin/validator/src/commands/bootstrap.rs | 2 +- .../get_transaction_encryption_key.rs | 2 +- .../src/server/validator_service/mod.rs | 2 +- .../src/server/validator_service/tests.rs | 47 +++++++------------ bin/validator/src/signers/mod.rs | 36 ++++++++++---- 6 files changed, 46 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 07f89383a1..6e8cbd2d43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,8 +3,6 @@ ## Unreleased - [BREAKING] Updated `miden-protocol` dependencies to use the `next` branch (v0.16). Block and transaction account updates now use the absolute `AccountPatch` representation instead of the relative `AccountDelta`, and the `miden-tx-batch-prover` crate was renamed to `miden-tx-batch` ([#2282](https://github.com/0xMiden/node/pull/2282)). -- Added the `GetTransactionEncryptionKey` endpoint to the validator and RPC APIs, returning the shared transaction encryption key attested by the serving validator's signing key. The shared secret is configured on the validator via `--encryption-key.hex`. `MIDEN_VALIDATOR_ENCRYPTION_KEY` and must be identical across the validator set ([#2319](https://github.com/0xMiden/node/issues/2319)). - ## v0.15.0 (2026-06-10) - Fixed the store dropping a fungible asset's callback flag when applying partial account deltas ([#2222](https://github.com/0xMiden/node/pull/2222)). diff --git a/bin/validator/src/commands/bootstrap.rs b/bin/validator/src/commands/bootstrap.rs index 37a568ffd4..5e6dd7f90b 100644 --- a/bin/validator/src/commands/bootstrap.rs +++ b/bin/validator/src/commands/bootstrap.rs @@ -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/server/validator_service/get_transaction_encryption_key.rs b/bin/validator/src/server/validator_service/get_transaction_encryption_key.rs index 9b5657fb88..d03703622b 100644 --- a/bin/validator/src/server/validator_service/get_transaction_encryption_key.rs +++ b/bin/validator/src/server/validator_service/get_transaction_encryption_key.rs @@ -33,7 +33,7 @@ impl grpc::server::validator_api::GetTransactionEncryptionKey for ValidatorServi // Built entirely from state fixed at construction, so the endpoint stays available while a // backup subscription holds the serve lock. Ok(grpc::transaction::TransactionEncryptionKey { - scheme: u32::from(u8::from(ValidatorEncryptor::SCHEME)), + scheme: ValidatorEncryptor::scheme_id(), key_id: self.encryptor.key_id(), public_key: self.encryptor.public_key().to_bytes(), signature: self.encryption_key_attestation.to_bytes(), diff --git a/bin/validator/src/server/validator_service/mod.rs b/bin/validator/src/server/validator_service/mod.rs index 3ab5e1763c..7add194f8f 100644 --- a/bin/validator/src/server/validator_service/mod.rs +++ b/bin/validator/src/server/validator_service/mod.rs @@ -279,7 +279,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 10add23536..c5093368ec 100644 --- a/bin/validator/src/server/validator_service/tests.rs +++ b/bin/validator/src/server/validator_service/tests.rs @@ -4,12 +4,12 @@ use miden_node_proto::generated::{self as proto}; use miden_node_proto::server::validator_api; use miden_node_store::{BlockStore, GenesisState}; use miden_node_utils::fee::test_fee_params; +use miden_protocol::Word; use miden_protocol::block::{BlockHeader, BlockInputs, ProposedBlock}; use miden_protocol::crypto::dsa::ecdsa_k256_keccak::{Signature, SigningKey}; use miden_protocol::crypto::dsa::eddsa_25519_sha512::KeyExchangeKey; use miden_protocol::testing::random_secret_key::random_secret_key; use miden_protocol::transaction::PartialBlockchain; -use miden_protocol::{Hasher, Word}; use miden_tx::utils::serde::{Deserializable, Serializable}; use super::{ValidatorError, ValidatorService}; @@ -718,28 +718,6 @@ async fn requests_run_concurrently() { // TRANSACTION ENCRYPTION KEY // ================================================================================================ -/// Recomputes the attestation commitment from response fields and the chain's genesis commitment. -fn attestation_commitment_of( - scheme: u32, - key_id: u32, - genesis_commitment: Word, - public_key: &[u8], -) -> Word { - let genesis_commitment = genesis_commitment.to_bytes(); - let mut payload = Vec::with_capacity( - ValidatorEncryptor::ATTESTATION_DOMAIN.len() - + 2 * size_of::() - + genesis_commitment.len() - + public_key.len(), - ); - payload.extend_from_slice(ValidatorEncryptor::ATTESTATION_DOMAIN); - payload.extend_from_slice(&scheme.to_le_bytes()); - payload.extend_from_slice(&key_id.to_le_bytes()); - payload.extend_from_slice(&genesis_commitment); - payload.extend_from_slice(public_key); - Hasher::hash(&payload) -} - /// 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. @@ -751,12 +729,16 @@ async fn transaction_encryption_key_is_attested() { let response = tv.call_get_transaction_encryption_key().await; let encryptor = test_encryptor(); - assert_eq!(response.scheme, u32::from(u8::from(ValidatorEncryptor::SCHEME))); + assert_eq!(response.scheme, ValidatorEncryptor::scheme_id()); assert_eq!(response.key_id, encryptor.key_id()); assert_eq!(response.public_key, encryptor.public_key().to_bytes()); - let commitment = - attestation_commitment_of(response.scheme, response.key_id, genesis, &response.public_key); + let commitment = ValidatorEncryptor::attestation_commitment_of( + response.scheme, + response.key_id, + genesis, + &response.public_key, + ); assert_eq!(commitment, encryptor.attestation_commitment(genesis)); let signature = @@ -801,20 +783,25 @@ async fn tampered_attestation_fails_verification() { let tampered_genesis = Word::try_from([9u64, 9, 9, 9]).unwrap(); let tampered_commitments = [ - attestation_commitment_of( + ValidatorEncryptor::attestation_commitment_of( response.scheme + 1, response.key_id, genesis, &response.public_key, ), - attestation_commitment_of( + ValidatorEncryptor::attestation_commitment_of( response.scheme, response.key_id.wrapping_add(1), genesis, &response.public_key, ), - attestation_commitment_of(response.scheme, response.key_id, genesis, &tampered_public_key), - attestation_commitment_of( + ValidatorEncryptor::attestation_commitment_of( + response.scheme, + response.key_id, + genesis, + &tampered_public_key, + ), + ValidatorEncryptor::attestation_commitment_of( response.scheme, response.key_id, tampered_genesis, diff --git a/bin/validator/src/signers/mod.rs b/bin/validator/src/signers/mod.rs index 869f31df09..5c647c896c 100644 --- a/bin/validator/src/signers/mod.rs +++ b/bin/validator/src/signers/mod.rs @@ -2,7 +2,6 @@ mod kms; pub use kms::KmsSigner; use miden_node_utils::spawn::spawn_blocking_in_current_span; use miden_protocol::Word; -use miden_protocol::block::BlockHeader; use miden_protocol::crypto::dsa::ecdsa_k256_keccak::{PublicKey, Signature, SigningKey}; use miden_protocol::crypto::dsa::eddsa_25519_sha512::{ KeyExchangeKey, @@ -43,11 +42,6 @@ impl ValidatorSigner { } } - /// Signs a block header using the configured signer. - pub async fn sign(&self, header: &BlockHeader) -> anyhow::Result { - self.sign_commitment(header.commitment()).await - } - /// Signs a commitment using the configured signer. pub async fn sign_commitment(&self, commitment: Word) -> anyhow::Result { let signature = match self { @@ -90,6 +84,11 @@ impl ValidatorEncryptor { Self::Local(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 { match self { @@ -117,9 +116,26 @@ impl ValidatorEncryptor { /// 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. pub fn attestation_commitment(&self, genesis_commitment: Word) -> Word { - let scheme = u32::from(u8::from(Self::SCHEME)); + Self::attestation_commitment_of( + Self::scheme_id(), + self.key_id(), + genesis_commitment, + &self.public_key().to_bytes(), + ) + } + + /// 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. + pub fn attestation_commitment_of( + scheme: u32, + key_id: u32, + genesis_commitment: Word, + public_key: &[u8], + ) -> Word { let genesis_commitment = genesis_commitment.to_bytes(); - let public_key = self.public_key().to_bytes(); let mut payload = Vec::with_capacity( Self::ATTESTATION_DOMAIN.len() + 2 * size_of::() @@ -128,9 +144,9 @@ impl ValidatorEncryptor { ); payload.extend_from_slice(Self::ATTESTATION_DOMAIN); payload.extend_from_slice(&scheme.to_le_bytes()); - payload.extend_from_slice(&self.key_id().to_le_bytes()); + payload.extend_from_slice(&key_id.to_le_bytes()); payload.extend_from_slice(&genesis_commitment); - payload.extend_from_slice(&public_key); + payload.extend_from_slice(public_key); miden_protocol::Hasher::hash(&payload) } From cfe69874af97b2284e0381e541f65e7b235c77f3 Mon Sep 17 00:00:00 2001 From: Juan Munoz Date: Fri, 17 Jul 2026 14:54:27 -0300 Subject: [PATCH 3/7] chore: address PR comments --- bin/validator/src/commands/mod.rs | 14 +- bin/validator/src/commands/start.rs | 7 +- bin/validator/src/lib.rs | 9 +- bin/validator/src/server/mod.rs | 9 +- .../get_transaction_encryption_key.rs | 8 +- .../src/server/validator_service/mod.rs | 20 +- .../src/server/validator_service/tests.rs | 96 +++--- bin/validator/src/signers/mod.rs | 292 +++++++++++------- crates/rpc/src/tests.rs | 2 +- docs/external/src/rpc/public-api.md | 4 +- proto/proto/types/transaction.proto | 16 +- 11 files changed, 291 insertions(+), 186 deletions(-) diff --git a/bin/validator/src/commands/mod.rs b/bin/validator/src/commands/mod.rs index 4670683f41..d772651483 100644 --- a/bin/validator/src/commands/mod.rs +++ b/bin/validator/src/commands/mod.rs @@ -3,6 +3,7 @@ mod start; use std::num::NonZeroUsize; use std::path::PathBuf; +use std::sync::Arc; use anyhow::Context; use clap::Parser; @@ -12,7 +13,13 @@ 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, LOG_TARGET, ValidatorEncryptor, ValidatorSigner}; +use miden_validator::{ + DataDirectory, + LOG_TARGET, + LocalX25519TransactionInputDecryptor, + TransactionInputDecryptor, + ValidatorSigner, +}; const ENV_DATA_DIRECTORY: &str = "MIDEN_VALIDATOR_DATA_DIRECTORY"; const ENV_LISTEN: &str = "MIDEN_VALIDATOR_LISTEN"; @@ -194,7 +201,8 @@ impl ValidatorCommand { .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 encryptor = ValidatorEncryptor::new_local(encryption_key); + let decryptor: Arc = + Arc::new(LocalX25519TransactionInputDecryptor::new(encryption_key)); let signer = if let Some(kms_key_id) = kms_key_id { ValidatorSigner::new_kms(kms_key_id).await? @@ -207,7 +215,7 @@ impl ValidatorCommand { address, grpc_options, signer, - encryptor, + decryptor, data_directory, sqlite_connection_pool_size, shutdown, diff --git a/bin/validator/src/commands/start.rs b/bin/validator/src/commands/start.rs index 068290c216..384a5489c4 100644 --- a/bin/validator/src/commands/start.rs +++ b/bin/validator/src/commands/start.rs @@ -1,18 +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, ValidatorEncryptor, ValidatorServer, ValidatorSigner}; +use miden_validator::{DataDirectory, TransactionInputDecryptor, ValidatorServer, ValidatorSigner}; // Starts the validator component. pub async fn start( address: SocketAddr, grpc_options: GrpcOptionsInternal, signer: ValidatorSigner, - encryptor: ValidatorEncryptor, + decryptor: Arc, data_directory: PathBuf, sqlite_connection_pool_size: NonZeroUsize, shutdown: CancellationToken, @@ -23,7 +24,7 @@ pub async fn start( address, grpc_options, signer, - encryptor, + decryptor, data_directory, sqlite_connection_pool_size, } diff --git a/bin/validator/src/lib.rs b/bin/validator/src/lib.rs index 41e7d6a1fe..8ffb2fe975 100644 --- a/bin/validator/src/lib.rs +++ b/bin/validator/src/lib.rs @@ -6,7 +6,14 @@ mod tx_validation; pub use data_directory::DataDirectory; pub use server::ValidatorServer; -pub use signers::{KmsSigner, ValidatorEncryptor, ValidatorSigner}; +pub use signers::{ + KmsSigner, + LocalX25519TransactionInputDecryptor, + TransactionEncryptionKeyInfo, + TransactionInputDecryptor, + ValidatorSigner, + attestation_commitment, +}; // CONSTANTS // ================================================================================================= diff --git a/bin/validator/src/server/mod.rs b/bin/validator/src/server/mod.rs index a7540b139b..abe8de2742 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, ValidatorEncryptor, ValidatorSigner}; +use crate::{DataDirectory, LOG_TARGET, TransactionInputDecryptor, ValidatorSigner}; mod validator_service; @@ -43,8 +43,9 @@ pub struct ValidatorServer { /// The signer used to sign blocks. pub signer: ValidatorSigner, - /// The shared transaction encryption key used to unseal encrypted transaction inputs. - pub encryptor: ValidatorEncryptor, + /// The decryptor for the shared transaction encryption key, used to unseal encrypted + /// transaction inputs. + pub decryptor: std::sync::Arc, /// The data directory for the validator component's database files. pub data_directory: DataDirectory, @@ -101,7 +102,7 @@ impl ValidatorServer { .add_service(validator_api::service( ValidatorService::new( self.signer, - self.encryptor, + self.decryptor, 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 index d03703622b..09137dcb21 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 @@ -3,7 +3,7 @@ use miden_node_utils::tracing::miden_instrument; use miden_tx::utils::serde::Serializable; use super::ValidatorService; -use crate::{COMPONENT, ValidatorEncryptor}; +use crate::COMPONENT; #[tonic::async_trait] impl grpc::server::validator_api::GetTransactionEncryptionKey for ValidatorService { @@ -33,9 +33,9 @@ impl grpc::server::validator_api::GetTransactionEncryptionKey for ValidatorServi // Built entirely from state fixed at construction, so the endpoint stays available while a // backup subscription holds the serve lock. Ok(grpc::transaction::TransactionEncryptionKey { - scheme: ValidatorEncryptor::scheme_id(), - key_id: self.encryptor.key_id(), - public_key: self.encryptor.public_key().to_bytes(), + scheme: self.encryption_key_info.scheme, + key_id: self.encryption_key_info.key_id.clone(), + public_key: self.encryption_key_info.public_key.clone(), signature: self.encryption_key_attestation.to_bytes(), }) } diff --git a/bin/validator/src/server/validator_service/mod.rs b/bin/validator/src/server/validator_service/mod.rs index 7add194f8f..9fd06be5d2 100644 --- a/bin/validator/src/server/validator_service/mod.rs +++ b/bin/validator/src/server/validator_service/mod.rs @@ -19,7 +19,8 @@ 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, ValidatorEncryptor, ValidatorSigner}; +use crate::signers::TransactionEncryptionKeyInfo; +use crate::{COMPONENT, TransactionInputDecryptor, ValidatorSigner}; #[cfg(test)] mod tests; @@ -76,7 +77,11 @@ pub enum ValidatorError { /// Implements the gRPC API for the validator. pub(crate) struct ValidatorService { signer: ValidatorSigner, - encryptor: ValidatorEncryptor, + /// Decryptor for transaction inputs sealed against the shared encryption key. + #[expect(dead_code, reason = "used by the submit path in a follow-up PR")] + decryptor: 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, @@ -102,7 +107,7 @@ pub(crate) struct ValidatorService { impl ValidatorService { pub(crate) async fn new( signer: ValidatorSigner, - encryptor: ValidatorEncryptor, + decryptor: Arc, db: Database, block_store: BlockStore, initial_chain_tip: u32, @@ -139,14 +144,19 @@ impl ValidatorService { .map_err(ValidatorError::DatabaseError)? .ok_or(ValidatorError::NoGenesisHeader)? .commitment(); + let encryption_key_info = decryptor + .encryption_key() + .await + .map_err(|err| ValidatorError::EncryptionKeyAttestationFailed(err.to_string()))?; let encryption_key_attestation = signer - .sign_commitment(encryptor.attestation_commitment(genesis_commitment)) + .sign_commitment(encryption_key_info.attestation_commitment(genesis_commitment)) .await .map_err(|err| ValidatorError::EncryptionKeyAttestationFailed(err.to_string()))?; Ok(Self { signer, - encryptor, + decryptor, + encryption_key_info, encryption_key_attestation, serve_lock: Arc::new(tokio::sync::RwLock::new(())), db: db.into(), diff --git a/bin/validator/src/server/validator_service/tests.rs b/bin/validator/src/server/validator_service/tests.rs index c5093368ec..772243a6d3 100644 --- a/bin/validator/src/server/validator_service/tests.rs +++ b/bin/validator/src/server/validator_service/tests.rs @@ -14,7 +14,8 @@ use miden_tx::utils::serde::{Deserializable, Serializable}; use super::{ValidatorError, ValidatorService}; use crate::db::{load_chain_tip, setup, upsert_block_header}; -use crate::{ValidatorEncryptor, ValidatorSigner}; +use crate::signers::attestation_commitment; +use crate::{LocalX25519TransactionInputDecryptor, TransactionInputDecryptor, ValidatorSigner}; // TEST HELPERS // ================================================================================================ @@ -22,12 +23,12 @@ use crate::{ValidatorEncryptor, ValidatorSigner}; /// The shared transaction encryption secret provisioned to every test validator. const TEST_ENCRYPTION_SECRET: [u8; 32] = [3u8; 32]; -/// Creates a [`ValidatorEncryptor`] from the shared test secret, modelling the identically -/// provisioned encryption key of a validator in the set. -fn test_encryptor() -> ValidatorEncryptor { +/// Creates a [`LocalX25519TransactionInputDecryptor`] from the shared test secret, modelling the +/// identically provisioned encryption key of a validator in the set. +fn test_decryptor() -> LocalX25519TransactionInputDecryptor { let key = KeyExchangeKey::read_from_bytes(&TEST_ENCRYPTION_SECRET) .expect("test secret should be a valid key exchange key"); - ValidatorEncryptor::new_local(key) + LocalX25519TransactionInputDecryptor::new(key) } /// Test harness that wraps a [`Validator`] and tracks the chain MMR state needed to construct valid @@ -50,9 +51,17 @@ impl TestValidator { let (temp_dir, db, block_store, genesis_header) = setup_db_with_genesis(&key).await; Self { - server: ValidatorService::new(signer, test_encryptor(), db, block_store, 0, 0, 0) - .await - .unwrap(), + server: ValidatorService::new( + signer, + std::sync::Arc::new(test_decryptor()), + db, + block_store, + 0, + 0, + 0, + ) + .await + .unwrap(), chain: PartialBlockchain::default(), chain_tip: genesis_header, _temp_dir: temp_dir, @@ -206,8 +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, test_encryptor(), db, block_store, 0, 0, 0).await; + let result = ValidatorService::new( + rogue_signer, + std::sync::Arc::new(test_decryptor()), + db, + block_store, + 0, + 0, + 0, + ) + .await; assert!( matches!(result, Err(ValidatorError::ValidatorKeyMismatch { .. })), "expected ValidatorKeyMismatch error", @@ -728,18 +745,14 @@ async fn transaction_encryption_key_is_attested() { let genesis = tv.chain_tip.commitment(); let response = tv.call_get_transaction_encryption_key().await; - let encryptor = test_encryptor(); - assert_eq!(response.scheme, ValidatorEncryptor::scheme_id()); - assert_eq!(response.key_id, encryptor.key_id()); - assert_eq!(response.public_key, encryptor.public_key().to_bytes()); + let info = test_decryptor().encryption_key().await.expect("key info should be available"); + assert_eq!(response.scheme, info.scheme); + assert_eq!(response.key_id, info.key_id); + assert_eq!(response.public_key, info.public_key); - let commitment = ValidatorEncryptor::attestation_commitment_of( - response.scheme, - response.key_id, - genesis, - &response.public_key, - ); - assert_eq!(commitment, encryptor.attestation_commitment(genesis)); + let commitment = + attestation_commitment(response.scheme, &response.key_id, genesis, &response.public_key); + assert_eq!(commitment, info.attestation_commitment(genesis)); let signature = Signature::read_from_bytes(&response.signature).expect("signature should deserialize"); @@ -780,30 +793,32 @@ async fn tampered_attestation_fails_verification() { 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(); let tampered_commitments = [ - ValidatorEncryptor::attestation_commitment_of( + attestation_commitment( response.scheme + 1, - response.key_id, + &response.key_id, genesis, &response.public_key, ), - ValidatorEncryptor::attestation_commitment_of( + attestation_commitment(response.scheme, &tampered_key_id, genesis, &response.public_key), + attestation_commitment( response.scheme, - response.key_id.wrapping_add(1), + &extended_key_id, genesis, - &response.public_key, + &response.public_key[1..], ), - ValidatorEncryptor::attestation_commitment_of( + attestation_commitment(response.scheme, &response.key_id, genesis, &tampered_public_key), + attestation_commitment( response.scheme, - response.key_id, - genesis, - &tampered_public_key, - ), - ValidatorEncryptor::attestation_commitment_of( - response.scheme, - response.key_id, + &response.key_id, tampered_genesis, &response.public_key, ), @@ -838,16 +853,19 @@ async fn response_key_seals_for_the_validator_set() { .seal_bytes_with_associated_data(&mut rng, plaintext, associated_data) .unwrap(); - let opened = test_encryptor() - .unseal_bytes_with_associated_data(sealed.clone(), associated_data) + let sealed = sealed.to_bytes(); + let opened = test_decryptor() + .decrypt_transaction_inputs(&sealed, associated_data) + .await .unwrap(); assert_eq!(opened.as_slice(), plaintext); assert!( - test_encryptor() - .unseal_bytes_with_associated_data(sealed, b"other associated data") + test_decryptor() + .decrypt_transaction_inputs(&sealed, b"other associated data") + .await .is_err(), - "unsealing must fail under mismatched associated data", + "decryption must fail under mismatched associated data", ); } diff --git a/bin/validator/src/signers/mod.rs b/bin/validator/src/signers/mod.rs index 5c647c896c..b8120837ad 100644 --- a/bin/validator/src/signers/mod.rs +++ b/bin/validator/src/signers/mod.rs @@ -7,8 +7,10 @@ use miden_protocol::crypto::dsa::eddsa_25519_sha512::{ KeyExchangeKey, PublicKey as EncryptionPublicKey, }; -use miden_protocol::crypto::ies::{IesError, IesScheme, SealedMessage, SealingKey, UnsealingKey}; -use miden_protocol::utils::serde::Serializable; +#[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 // ================================================================================================= @@ -58,30 +60,114 @@ impl ValidatorSigner { } } -// VALIDATOR ENCRYPTOR +// TRANSACTION INPUT DECRYPTOR // ================================================================================================= -/// Encryption-key counterpart to [`ValidatorSigner`], wrapping the shared transaction encryption +/// 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 secret material behind this type 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. -pub enum ValidatorEncryptor { - Local(KeyExchangeKey), +/// 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 +/// [`LocalX25519TransactionInputDecryptor`]) or delegate decryption to an external system such as +/// a TEE that only exposes a decrypt operation. +#[tonic::async_trait] +pub trait TransactionInputDecryptor: 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, } -impl ValidatorEncryptor { +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) + } +} + +/// 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`, binding every field of the attested +/// response to the signature. The scheme 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. +pub fn attestation_commitment( + scheme: u32, + key_id: &[u8], + genesis_commitment: Word, + public_key: &[u8], +) -> Word { + let genesis_commitment = genesis_commitment.to_bytes(); + let key_id_len = u32::try_from(key_id.len()) + .expect("key id length must fit in u32") + .to_le_bytes(); + let public_key_len = u32::try_from(public_key.len()) + .expect("public key length must fit in u32") + .to_le_bytes(); + let mut payload = Vec::with_capacity( + ATTESTATION_DOMAIN.len() + + 3 * size_of::() + + key_id.len() + + genesis_commitment.len() + + public_key.len(), + ); + payload.extend_from_slice(ATTESTATION_DOMAIN); + payload.extend_from_slice(&scheme.to_le_bytes()); + payload.extend_from_slice(&key_id_len); + payload.extend_from_slice(key_id); + payload.extend_from_slice(&genesis_commitment); + payload.extend_from_slice(&public_key_len); + payload.extend_from_slice(public_key); + miden_protocol::Hasher::hash(&payload) +} + +/// [`TransactionInputDecryptor`] backed by a locally provisioned X25519 shared secret. +pub struct LocalX25519TransactionInputDecryptor { + secret_key: KeyExchangeKey, +} + +impl LocalX25519TransactionInputDecryptor { /// The IES scheme used for transaction input encryption. pub const SCHEME: IesScheme = IesScheme::X25519XChaCha20Poly1305; - /// 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"; - - /// Constructs an encryptor from a locally provisioned shared secret. - pub fn new_local(secret_key: KeyExchangeKey) -> Self { - Self::Local(secret_key) + /// Constructs a decryptor from a locally provisioned shared secret. + pub fn new(secret_key: KeyExchangeKey) -> Self { + Self { secret_key } } /// Returns the wire representation of [`Self::SCHEME`]. @@ -91,75 +177,44 @@ impl ValidatorEncryptor { /// Returns the public key of the shared encryption key. pub fn public_key(&self) -> EncryptionPublicKey { - match self { - Self::Local(key) => key.public_key(), - } + self.secret_key.public_key() } - /// Returns the opaque identifier of the current encryption key. - pub fn key_id(&self) -> u32 { - let commitment = self.public_key().to_commitment().to_bytes(); - u32::from_le_bytes(commitment[..4].try_into().expect("commitment is at least 4 bytes")) + /// 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()) } +} - /// Returns the commitment signed by a validator to attest the encryption key. - /// - /// Computed as the Poseidon2 hash of - /// `ATTESTATION_DOMAIN || scheme || key_id || genesis_commitment || public_key`, binding - /// every field of the attested response to the signature. The scheme and key id are bound at - /// their full wire width (4 bytes little-endian each) so no wire value maps to another - /// 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. - pub fn attestation_commitment(&self, genesis_commitment: Word) -> Word { - Self::attestation_commitment_of( - Self::scheme_id(), - self.key_id(), - genesis_commitment, - &self.public_key().to_bytes(), - ) - } - - /// 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. - pub fn attestation_commitment_of( - scheme: u32, - key_id: u32, - genesis_commitment: Word, - public_key: &[u8], - ) -> Word { - let genesis_commitment = genesis_commitment.to_bytes(); - let mut payload = Vec::with_capacity( - Self::ATTESTATION_DOMAIN.len() - + 2 * size_of::() - + genesis_commitment.len() - + public_key.len(), - ); - payload.extend_from_slice(Self::ATTESTATION_DOMAIN); - payload.extend_from_slice(&scheme.to_le_bytes()); - payload.extend_from_slice(&key_id.to_le_bytes()); - payload.extend_from_slice(&genesis_commitment); - payload.extend_from_slice(public_key); - miden_protocol::Hasher::hash(&payload) +#[tonic::async_trait] +impl TransactionInputDecryptor for LocalX25519TransactionInputDecryptor { + 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(), + }) } - /// Unseals a message encrypted against the shared encryption key. - pub fn unseal_bytes_with_associated_data( + async fn decrypt_transaction_inputs( &self, - message: SealedMessage, + ciphertext: &[u8], associated_data: &[u8], - ) -> Result, IesError> { - match self { - Self::Local(key) => UnsealingKey::X25519XChaCha20Poly1305(key.clone()) - .unseal_bytes_with_associated_data(message, associated_data), - } + ) -> 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") } } @@ -170,71 +225,70 @@ mod tests { use super::*; - /// Loading the same shared secret must yield the same public key, key id, and attestation - /// commitment on every validator instance. - #[test] - fn same_secret_yields_same_public_material() { - let secret = [7u8; 32]; + fn decryptor_from(secret: &[u8; 32]) -> LocalX25519TransactionInputDecryptor { + LocalX25519TransactionInputDecryptor::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 key_a = KeyExchangeKey::read_from_bytes(&secret).unwrap(); - let key_b = KeyExchangeKey::read_from_bytes(&secret).unwrap(); - let encryptor_a = ValidatorEncryptor::new_local(key_a); - let encryptor_b = ValidatorEncryptor::new_local(key_b); - - assert_eq!(encryptor_a.public_key(), encryptor_b.public_key()); - assert_eq!(encryptor_a.key_id(), encryptor_b.key_id()); - assert_eq!( - encryptor_a.attestation_commitment(genesis), - encryptor_b.attestation_commitment(genesis) - ); + let info_a = decryptor_from(&[7u8; 32]).encryption_key().await.unwrap(); + let info_b = decryptor_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. - #[test] - fn different_secrets_yield_different_public_material() { + #[tokio::test] + async fn different_secrets_yield_different_public_material() { let genesis = Word::try_from([1u64, 2, 3, 4]).unwrap(); - let key_a = KeyExchangeKey::read_from_bytes(&[7u8; 32]).unwrap(); - let key_b = KeyExchangeKey::read_from_bytes(&[8u8; 32]).unwrap(); - let encryptor_a = ValidatorEncryptor::new_local(key_a); - let encryptor_b = ValidatorEncryptor::new_local(key_b); - - assert_ne!(encryptor_a.public_key(), encryptor_b.public_key()); - assert_ne!(encryptor_a.key_id(), encryptor_b.key_id()); - assert_ne!( - encryptor_a.attestation_commitment(genesis), - encryptor_b.attestation_commitment(genesis) - ); + let info_a = decryptor_from(&[7u8; 32]).encryption_key().await.unwrap(); + let info_b = decryptor_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 encryptor's sealing key must unseal to the original plaintext, - /// and unsealing must reject a mismatched associated data or a mismatched key. - #[test] - fn seal_unseal_roundtrip() { + /// A message sealed against the decryptor'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 encryptor = - ValidatorEncryptor::new_local(KeyExchangeKey::read_from_bytes(&[7u8; 32]).unwrap()); + let decryptor = decryptor_from(&[7u8; 32]); let plaintext = b"transaction inputs"; let associated_data = b"scheme|key_id|chain|tx"; - let sealed = encryptor + let sealed = decryptor .sealing_key() .seal_bytes_with_associated_data(&mut rng, plaintext, associated_data) - .unwrap(); - let opened = encryptor - .unseal_bytes_with_associated_data(sealed.clone(), associated_data) - .unwrap(); + .unwrap() + .to_bytes(); + let opened = decryptor.decrypt_transaction_inputs(&sealed, associated_data).await.unwrap(); assert_eq!(opened.as_slice(), plaintext); // Mismatched associated data must fail authentication. assert!( - encryptor - .unseal_bytes_with_associated_data(sealed.clone(), b"wrong associated data") + decryptor + .decrypt_transaction_inputs(&sealed, b"wrong associated data") + .await .is_err() ); - // A different shared secret must fail to unseal. - let other = - ValidatorEncryptor::new_local(KeyExchangeKey::read_from_bytes(&[8u8; 32]).unwrap()); - assert!(other.unseal_bytes_with_associated_data(sealed, associated_data).is_err()); + // A different shared secret must fail to decrypt. + let other = decryptor_from(&[8u8; 32]); + assert!(other.decrypt_transaction_inputs(&sealed, associated_data).await.is_err()); + + // Garbage ciphertext must fail to deserialize. + assert!( + decryptor + .decrypt_transaction_inputs(b"not a sealed message", associated_data) + .await + .is_err() + ); } } diff --git a/crates/rpc/src/tests.rs b/crates/rpc/src/tests.rs index a3c4f9ad9c..18ecf1676b 100644 --- a/crates/rpc/src/tests.rs +++ b/crates/rpc/src/tests.rs @@ -798,7 +798,7 @@ async fn start_validator( fn test_encryption_key() -> proto::transaction::TransactionEncryptionKey { proto::transaction::TransactionEncryptionKey { scheme: 1, - key_id: 0xDEAD_BEEF, + key_id: vec![0xDE, 0xAD, 0xBE, 0xEF], public_key: vec![7; 32], signature: vec![9; 65], } diff --git a/docs/external/src/rpc/public-api.md b/docs/external/src/rpc/public-api.md index 1c2536284a..53d2dd154a 100644 --- a/docs/external/src/rpc/public-api.md +++ b/docs/external/src/rpc/public-api.md @@ -43,7 +43,9 @@ the whole validator set, while the attesting signature is specific to the servin signature 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. +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/proto/proto/types/transaction.proto b/proto/proto/types/transaction.proto index cc3310410d..8aef769a8e 100644 --- a/proto/proto/types/transaction.proto +++ b/proto/proto/types/transaction.proto @@ -75,7 +75,7 @@ message TransactionEncryptionKey { uint32 scheme = 1; // Opaque identifier of the current encryption key. Changes when the key rotates. - fixed32 key_id = 2; + bytes key_id = 2; // Raw public key bytes of the shared encryption key. // @@ -85,16 +85,20 @@ message TransactionEncryptionKey { bytes public_key = 3; // The serving validator's signature over the attestation commitment: the Poseidon2 byte-mode - // hash of `domain_tag || scheme || key_id || genesis_commitment || public_key`, where - // `domain_tag` is the ASCII string `MIDEN_TX_ENCRYPTION_KEY_ATTESTATION_V1`, scheme and key_id - // are bound at their full wire width (4 bytes little-endian each), and the genesis block - // commitment ties the attestation to one network. The canonical construction is - // `miden_validator::ValidatorEncryptor::attestation_commitment`. + // hash of `domain_tag || scheme || len(key_id) || key_id || genesis_commitment || + // len(public_key) || public_key`, where `domain_tag` is the ASCII string + // `MIDEN_TX_ENCRYPTION_KEY_ATTESTATION_V1`, the scheme and the length prefixes are encoded as + // 4 bytes little-endian, and the genesis block commitment ties the attestation to one network. + // The canonical construction is `miden_validator::attestation_commitment`. // // Encoded using [miden_serde_utils::Serializable] implementation for // [miden_protocol::crypto::dsa::ecdsa_k256_keccak::Signature]. Verifiable against the // validator's signing key committed in block headers. bytes signature = 4; + + // Reserved for future attestation evidence beyond the validator signature, e.g. a TEE quote, + // signature chain, compose hash, app measurement, epoch, or accepted measurement set. + reserved 5 to 9; } // Represents a transaction ID. From fcb3e8b2df71b266d2c3ed29a9d82c9ac93dd604 Mon Sep 17 00:00:00 2001 From: Juan Munoz Date: Mon, 20 Jul 2026 12:16:41 -0300 Subject: [PATCH 4/7] chore: address PR comments --- CHANGELOG.md | 2 + bin/validator/src/commands/bootstrap.rs | 6 +- bin/validator/src/commands/mod.rs | 94 ++++++------- bin/validator/src/commands/start.rs | 6 +- bin/validator/src/lib.rs | 4 +- bin/validator/src/server/mod.rs | 8 +- .../get_transaction_encryption_key.rs | 16 ++- .../src/server/validator_service/mod.rs | 12 +- .../src/server/validator_service/tests.rs | 70 ++++++---- bin/validator/src/signers/mod.rs | 130 ++++++++++++------ .../api/get_transaction_encryption_key.rs | 4 + crates/rpc/src/tests.rs | 13 +- .../network-operator/bootstrap-and-genesis.md | 4 +- docs/external/src/rpc/public-api.md | 18 +-- docs/internal/src/validator.md | 9 +- proto/proto/internal/validator.proto | 8 +- proto/proto/rpc.proto | 12 +- proto/proto/types/transaction.proto | 86 +++++++++--- scripts/run-node.sh | 4 +- 19 files changed, 324 insertions(+), 182 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e8cbd2d43..654e347362 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ ## Unreleased - [BREAKING] Updated `miden-protocol` dependencies to use the `next` branch (v0.16). Block and transaction account updates now use the absolute `AccountPatch` representation instead of the relative `AccountDelta`, and the `miden-tx-batch-prover` crate was renamed to `miden-tx-batch` ([#2282](https://github.com/0xMiden/node/pull/2282)). +- Added the `GetTransactionEncryptionKey` endpoint to the validator and RPC APIs, returning the shared transaction encryption key attested by the serving validator's signing key. The shared secret is configured on the validator via `--encryption-key.hex` / `MIDEN_VALIDATOR_ENCRYPTION_KEY` and must be identical across the validator set ([#2342](https://github.com/0xMiden/node/pull/2342)). + ## v0.15.0 (2026-06-10) - Fixed the store dropping a fungible asset's callback flag when applying partial account deltas ([#2222](https://github.com/0xMiden/node/pull/2222)). diff --git a/bin/validator/src/commands/bootstrap.rs b/bin/validator/src/commands/bootstrap.rs index 5e6dd7f90b..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(), diff --git a/bin/validator/src/commands/mod.rs b/bin/validator/src/commands/mod.rs index d772651483..76249febc2 100644 --- a/bin/validator/src/commands/mod.rs +++ b/bin/validator/src/commands/mod.rs @@ -16,21 +16,21 @@ use miden_protocol::utils::serde::Deserializable; use miden_validator::{ DataDirectory, LOG_TARGET, - LocalX25519TransactionInputDecryptor, - TransactionInputDecryptor, + 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_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. @@ -69,9 +69,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. @@ -109,26 +109,26 @@ 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" )] - 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 = "key.kms-id", - env = ENV_KMS_KEY_ID, - value_name = "VALIDATOR_KMS_KEY_ID", - group = "key" + long = "signing-key.kms-id", + env = ENV_SIGNING_KEY_KMS_ID, + value_name = "VALIDATOR_SIGNING_KEY_KMS_ID", + group = "signing_key" )] - kms_key_id: Option, + signing_key_kms_id: Option, /// Hex-encoded shared secret of the transaction encryption key. /// @@ -155,7 +155,7 @@ impl ValidatorCommand { data_directory, sqlite_connection_pool_size, genesis_config_file, - validator_key, + signing_key, } => { bootstrap::bootstrap( &genesis_block_directory, @@ -163,7 +163,7 @@ impl ValidatorCommand { &data_directory, sqlite_connection_pool_size, genesis_config_file.as_ref(), - validator_key, + signing_key, ) .await }, @@ -177,9 +177,9 @@ 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, .. @@ -201,13 +201,13 @@ impl ValidatorCommand { .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 decryptor: Arc = - Arc::new(LocalX25519TransactionInputDecryptor::new(encryption_key)); + let decrypter: Arc = + Arc::new(LocalX25519TransactionInputDecrypter::new(encryption_key)); - let signer = if let Some(kms_key_id) = kms_key_id { + 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(validator_key)?.as_ref())?; + let signer = SigningKey::read_from_bytes(hex::decode(signing_key)?.as_ref())?; ValidatorSigner::new_local(signer) }; @@ -215,7 +215,7 @@ impl ValidatorCommand { address, grpc_options, signer, - decryptor, + decrypter, data_directory, sqlite_connection_pool_size, shutdown, @@ -233,42 +233,42 @@ 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)) } } diff --git a/bin/validator/src/commands/start.rs b/bin/validator/src/commands/start.rs index 384a5489c4..f93fe8a016 100644 --- a/bin/validator/src/commands/start.rs +++ b/bin/validator/src/commands/start.rs @@ -6,14 +6,14 @@ use std::sync::Arc; use anyhow::Context; use miden_node_utils::clap::GrpcOptionsInternal; use miden_node_utils::shutdown::CancellationToken; -use miden_validator::{DataDirectory, TransactionInputDecryptor, ValidatorServer, ValidatorSigner}; +use miden_validator::{DataDirectory, TransactionInputDecrypter, ValidatorServer, ValidatorSigner}; // Starts the validator component. pub async fn start( address: SocketAddr, grpc_options: GrpcOptionsInternal, signer: ValidatorSigner, - decryptor: Arc, + decrypter: Arc, data_directory: PathBuf, sqlite_connection_pool_size: NonZeroUsize, shutdown: CancellationToken, @@ -24,7 +24,7 @@ pub async fn start( address, grpc_options, signer, - decryptor, + decrypter, data_directory, sqlite_connection_pool_size, } diff --git a/bin/validator/src/lib.rs b/bin/validator/src/lib.rs index 8ffb2fe975..125015f976 100644 --- a/bin/validator/src/lib.rs +++ b/bin/validator/src/lib.rs @@ -8,9 +8,9 @@ pub use data_directory::DataDirectory; pub use server::ValidatorServer; pub use signers::{ KmsSigner, - LocalX25519TransactionInputDecryptor, + LocalX25519TransactionInputDecrypter, TransactionEncryptionKeyInfo, - TransactionInputDecryptor, + TransactionInputDecrypter, ValidatorSigner, attestation_commitment, }; diff --git a/bin/validator/src/server/mod.rs b/bin/validator/src/server/mod.rs index abe8de2742..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, TransactionInputDecryptor, ValidatorSigner}; +use crate::{DataDirectory, LOG_TARGET, TransactionInputDecrypter, ValidatorSigner}; mod validator_service; @@ -43,9 +43,9 @@ pub struct ValidatorServer { /// The signer used to sign blocks. pub signer: ValidatorSigner, - /// The decryptor for the shared transaction encryption key, used to unseal encrypted + /// The decrypter for the shared transaction encryption key, used to unseal encrypted /// transaction inputs. - pub decryptor: std::sync::Arc, + pub decrypter: std::sync::Arc, /// The data directory for the validator component's database files. pub data_directory: DataDirectory, @@ -102,7 +102,7 @@ impl ValidatorServer { .add_service(validator_api::service( ValidatorService::new( self.signer, - self.decryptor, + 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 index 09137dcb21..d916eb4c18 100644 --- a/bin/validator/src/server/validator_service/get_transaction_encryption_key.rs +++ b/bin/validator/src/server/validator_service/get_transaction_encryption_key.rs @@ -33,10 +33,22 @@ impl grpc::server::validator_api::GetTransactionEncryptionKey for ValidatorServi // Built entirely from state fixed at construction, so the endpoint stays available while a // backup subscription holds the serve lock. Ok(grpc::transaction::TransactionEncryptionKey { - scheme: self.encryption_key_info.scheme, + 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(), - signature: self.encryption_key_attestation.to_bytes(), + 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 9fd06be5d2..d27c12692d 100644 --- a/bin/validator/src/server/validator_service/mod.rs +++ b/bin/validator/src/server/validator_service/mod.rs @@ -20,7 +20,7 @@ use tokio::sync::{Semaphore, watch}; use crate::db::{find_unvalidated_transactions, load_block_header, load_chain_tip}; use crate::signers::TransactionEncryptionKeyInfo; -use crate::{COMPONENT, TransactionInputDecryptor, ValidatorSigner}; +use crate::{COMPONENT, TransactionInputDecrypter, ValidatorSigner}; #[cfg(test)] mod tests; @@ -77,9 +77,9 @@ pub enum ValidatorError { /// Implements the gRPC API for the validator. pub(crate) struct ValidatorService { signer: ValidatorSigner, - /// Decryptor for transaction inputs sealed against the shared encryption key. + /// Decrypter for transaction inputs sealed against the shared encryption key. #[expect(dead_code, reason = "used by the submit path in a follow-up PR")] - decryptor: Arc, + 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 @@ -107,7 +107,7 @@ pub(crate) struct ValidatorService { impl ValidatorService { pub(crate) async fn new( signer: ValidatorSigner, - decryptor: Arc, + decrypter: Arc, db: Database, block_store: BlockStore, initial_chain_tip: u32, @@ -144,7 +144,7 @@ impl ValidatorService { .map_err(ValidatorError::DatabaseError)? .ok_or(ValidatorError::NoGenesisHeader)? .commitment(); - let encryption_key_info = decryptor + let encryption_key_info = decrypter .encryption_key() .await .map_err(|err| ValidatorError::EncryptionKeyAttestationFailed(err.to_string()))?; @@ -155,7 +155,7 @@ impl ValidatorService { Ok(Self { signer, - decryptor, + decrypter, encryption_key_info, encryption_key_attestation, serve_lock: Arc::new(tokio::sync::RwLock::new(())), diff --git a/bin/validator/src/server/validator_service/tests.rs b/bin/validator/src/server/validator_service/tests.rs index 772243a6d3..15e0f57e84 100644 --- a/bin/validator/src/server/validator_service/tests.rs +++ b/bin/validator/src/server/validator_service/tests.rs @@ -14,8 +14,8 @@ use miden_tx::utils::serde::{Deserializable, Serializable}; use super::{ValidatorError, ValidatorService}; use crate::db::{load_chain_tip, setup, upsert_block_header}; -use crate::signers::attestation_commitment; -use crate::{LocalX25519TransactionInputDecryptor, TransactionInputDecryptor, ValidatorSigner}; +use crate::signers::{NextEncryptionKeyInfo, attestation_commitment}; +use crate::{LocalX25519TransactionInputDecrypter, TransactionInputDecrypter, ValidatorSigner}; // TEST HELPERS // ================================================================================================ @@ -23,12 +23,12 @@ use crate::{LocalX25519TransactionInputDecryptor, TransactionInputDecryptor, Val /// The shared transaction encryption secret provisioned to every test validator. const TEST_ENCRYPTION_SECRET: [u8; 32] = [3u8; 32]; -/// Creates a [`LocalX25519TransactionInputDecryptor`] from the shared test secret, modelling the +/// Creates a [`LocalX25519TransactionInputDecrypter`] from the shared test secret, modelling the /// identically provisioned encryption key of a validator in the set. -fn test_decryptor() -> LocalX25519TransactionInputDecryptor { +fn test_decrypter() -> LocalX25519TransactionInputDecrypter { let key = KeyExchangeKey::read_from_bytes(&TEST_ENCRYPTION_SECRET) .expect("test secret should be a valid key exchange key"); - LocalX25519TransactionInputDecryptor::new(key) + LocalX25519TransactionInputDecrypter::new(key) } /// Test harness that wraps a [`Validator`] and tracks the chain MMR state needed to construct valid @@ -53,7 +53,7 @@ impl TestValidator { Self { server: ValidatorService::new( signer, - std::sync::Arc::new(test_decryptor()), + std::sync::Arc::new(test_decrypter()), db, block_store, 0, @@ -217,7 +217,7 @@ async fn signing_key_mismatch_rejected() { let result = ValidatorService::new( rogue_signer, - std::sync::Arc::new(test_decryptor()), + std::sync::Arc::new(test_decrypter()), db, block_store, 0, @@ -745,17 +745,26 @@ async fn transaction_encryption_key_is_attested() { let genesis = tv.chain_tip.commitment(); let response = tv.call_get_transaction_encryption_key().await; - let info = test_decryptor().encryption_key().await.expect("key info should be available"); - assert_eq!(response.scheme, info.scheme); + 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(response.scheme, &response.key_id, genesis, &response.public_key); + 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(&response.signature).expect("signature should deserialize"); + 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", @@ -776,7 +785,7 @@ async fn shared_key_is_attested_per_validator() { assert_eq!(response_a.key_id, response_b.key_id); assert_eq!(response_a.public_key, response_b.public_key); assert_ne!( - response_a.signature, response_b.signature, + response_a.attestations[0].signature, response_b.attestations[0].signature, "each validator must attest with its own signing key", ); } @@ -788,8 +797,9 @@ 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.signature).unwrap(); + 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; @@ -800,27 +810,33 @@ async fn tampered_attestation_fails_verification() { 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( - response.scheme + 1, + scheme, &response.key_id, - genesis, + tampered_genesis, &response.public_key, + None, ), - attestation_commitment(response.scheme, &tampered_key_id, genesis, &response.public_key), attestation_commitment( - response.scheme, - &extended_key_id, - genesis, - &response.public_key[1..], - ), - attestation_commitment(response.scheme, &response.key_id, genesis, &tampered_public_key), - attestation_commitment( - response.scheme, + scheme, &response.key_id, - tampered_genesis, + genesis, &response.public_key, + Some(&injected_next_key), ), ]; for commitment in tampered_commitments { @@ -854,14 +870,14 @@ async fn response_key_seals_for_the_validator_set() { .unwrap(); let sealed = sealed.to_bytes(); - let opened = test_decryptor() + let opened = test_decrypter() .decrypt_transaction_inputs(&sealed, associated_data) .await .unwrap(); assert_eq!(opened.as_slice(), plaintext); assert!( - test_decryptor() + test_decrypter() .decrypt_transaction_inputs(&sealed, b"other associated data") .await .is_err(), diff --git a/bin/validator/src/signers/mod.rs b/bin/validator/src/signers/mod.rs index b8120837ad..e6f0c4509a 100644 --- a/bin/validator/src/signers/mod.rs +++ b/bin/validator/src/signers/mod.rs @@ -60,7 +60,7 @@ impl ValidatorSigner { } } -// TRANSACTION INPUT DECRYPTOR +// TRANSACTION INPUT DECRYPTER // ================================================================================================= /// Domain tag prefixed to the attestation payload, separating key attestations from block header @@ -76,10 +76,10 @@ pub const ATTESTATION_DOMAIN: &[u8] = b"MIDEN_TX_ENCRYPTION_KEY_ATTESTATION_V1"; /// /// The interface deliberately does not assume that secret key bytes exist in the validator /// process: an implementation may hold a local secret (see -/// [`LocalX25519TransactionInputDecryptor`]) or delegate decryption to an external system such as +/// [`LocalX25519TransactionInputDecrypter`]) or delegate decryption to an external system such as /// a TEE that only exposes a decrypt operation. #[tonic::async_trait] -pub trait TransactionInputDecryptor: Send + Sync { +pub trait TransactionInputDecrypter: Send + Sync { /// Returns the public metadata of the current encryption key. async fn encryption_key(&self) -> anyhow::Result; @@ -104,12 +104,35 @@ pub struct TransactionEncryptionKeyInfo { 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) + attestation_commitment( + self.scheme, + &self.key_id, + genesis_commitment, + &self.public_key, + self.next_key.as_ref(), + ) } } @@ -120,52 +143,77 @@ impl TransactionEncryptionKeyInfo { /// 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`, binding every field of the attested -/// response to the signature. The scheme 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. +/// 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 a single `0` byte when no rotation is scheduled, or `1` followed by +/// the next key's `scheme || len(key_id) || key_id || len(public_key) || public_key || +/// rotation_block_num` otherwise, so 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 key_id_len = u32::try_from(key_id.len()) - .expect("key id length must fit in u32") - .to_le_bytes(); - let public_key_len = u32::try_from(public_key.len()) - .expect("public key length must fit in u32") - .to_le_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(), + + public_key.len() + + 1 + + next_key_size, ); payload.extend_from_slice(ATTESTATION_DOMAIN); payload.extend_from_slice(&scheme.to_le_bytes()); - payload.extend_from_slice(&key_id_len); - payload.extend_from_slice(key_id); + extend_with_length_prefixed(&mut payload, key_id, "key id"); payload.extend_from_slice(&genesis_commitment); - payload.extend_from_slice(&public_key_len); - payload.extend_from_slice(public_key); + extend_with_length_prefixed(&mut payload, public_key, "public key"); + match next_key { + None => payload.push(0), + Some(next) => { + payload.push(1); + 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) } -/// [`TransactionInputDecryptor`] backed by a locally provisioned X25519 shared secret. -pub struct LocalX25519TransactionInputDecryptor { +/// 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 LocalX25519TransactionInputDecryptor { +impl LocalX25519TransactionInputDecrypter { /// The IES scheme used for transaction input encryption. pub const SCHEME: IesScheme = IesScheme::X25519XChaCha20Poly1305; - /// Constructs a decryptor from a locally provisioned shared secret. + /// Constructs a decrypter from a locally provisioned shared secret. pub fn new(secret_key: KeyExchangeKey) -> Self { Self { secret_key } } @@ -194,12 +242,13 @@ impl LocalX25519TransactionInputDecryptor { } #[tonic::async_trait] -impl TransactionInputDecryptor for LocalX25519TransactionInputDecryptor { +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, }) } @@ -218,6 +267,9 @@ impl TransactionInputDecryptor for LocalX25519TransactionInputDecryptor { } } +// TESTS +// ================================================================================================= + #[cfg(test)] mod tests { use miden_protocol::utils::serde::Deserializable; @@ -225,8 +277,8 @@ mod tests { use super::*; - fn decryptor_from(secret: &[u8; 32]) -> LocalX25519TransactionInputDecryptor { - LocalX25519TransactionInputDecryptor::new(KeyExchangeKey::read_from_bytes(secret).unwrap()) + 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 @@ -234,8 +286,8 @@ mod tests { #[tokio::test] async fn same_secret_yields_same_public_material() { let genesis = Word::try_from([1u64, 2, 3, 4]).unwrap(); - let info_a = decryptor_from(&[7u8; 32]).encryption_key().await.unwrap(); - let info_b = decryptor_from(&[7u8; 32]).encryption_key().await.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)); @@ -245,8 +297,8 @@ mod tests { #[tokio::test] async fn different_secrets_yield_different_public_material() { let genesis = Word::try_from([1u64, 2, 3, 4]).unwrap(); - let info_a = decryptor_from(&[7u8; 32]).encryption_key().await.unwrap(); - let info_b = decryptor_from(&[8u8; 32]).encryption_key().await.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); @@ -254,38 +306,38 @@ mod tests { assert_ne!(info_a.attestation_commitment(genesis), info_b.attestation_commitment(genesis)); } - /// A message sealed against the decryptor's sealing key must decrypt to the original plaintext, + /// 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 decryptor = decryptor_from(&[7u8; 32]); + let decrypter = decrypter_from(&[7u8; 32]); let plaintext = b"transaction inputs"; let associated_data = b"scheme|key_id|chain|tx"; - let sealed = decryptor + let sealed = decrypter .sealing_key() .seal_bytes_with_associated_data(&mut rng, plaintext, associated_data) .unwrap() .to_bytes(); - let opened = decryptor.decrypt_transaction_inputs(&sealed, associated_data).await.unwrap(); + let opened = decrypter.decrypt_transaction_inputs(&sealed, associated_data).await.unwrap(); assert_eq!(opened.as_slice(), plaintext); // Mismatched associated data must fail authentication. assert!( - decryptor + decrypter .decrypt_transaction_inputs(&sealed, b"wrong associated data") .await .is_err() ); // A different shared secret must fail to decrypt. - let other = decryptor_from(&[8u8; 32]); + let other = decrypter_from(&[8u8; 32]); assert!(other.decrypt_transaction_inputs(&sealed, associated_data).await.is_err()); // Garbage ciphertext must fail to deserialize. assert!( - decryptor + decrypter .decrypt_transaction_inputs(b"not a sealed message", associated_data) .await .is_err() 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 7d80a651e1..7675234c37 100644 --- a/crates/rpc/src/server/api/get_transaction_encryption_key.rs +++ b/crates/rpc/src/server/api/get_transaction_encryption_key.rs @@ -40,6 +40,8 @@ impl proto::server::rpc_api::GetTransactionEncryptionKey for RpcService { } match &self.mode { + // A full node configured with a validator connection asks it directly, same as the + // sequencer. RpcMode::Sequencer { validator, .. } | RpcMode::FullNode { validator: Some(validator), .. } => validator .as_ref() @@ -47,6 +49,8 @@ impl proto::server::rpc_api::GetTransactionEncryptionKey for RpcService { .get_transaction_encryption_key(forwarded_request) .await .map(tonic::Response::into_inner), + // A full node without a validator connection relays the request to its upstream RPC, + // which forwards it towards a validator in turn. RpcMode::FullNode { source_rpc, validator: None, .. } => source_rpc .as_ref() .clone() diff --git a/crates/rpc/src/tests.rs b/crates/rpc/src/tests.rs index 18ecf1676b..62dcdd7d2b 100644 --- a/crates/rpc/src/tests.rs +++ b/crates/rpc/src/tests.rs @@ -797,10 +797,19 @@ async fn start_validator( /// survive the passthrough unchanged. fn test_encryption_key() -> proto::transaction::TransactionEncryptionKey { proto::transaction::TransactionEncryptionKey { - scheme: 1, + scheme: proto::transaction::IesScheme::X25519Xchacha20Poly1305 as i32, key_id: vec![0xDE, 0xAD, 0xBE, 0xEF], public_key: vec![7; 32], - signature: vec![9; 65], + 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/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/rpc/public-api.md b/docs/external/src/rpc/public-api.md index 53d2dd154a..5e92b9b5e3 100644 --- a/docs/external/src/rpc/public-api.md +++ b/docs/external/src/rpc/public-api.md @@ -32,15 +32,15 @@ grpcurl rpc.testnet.miden.io:443 describe rpc.Api ## Transaction Submission -| Method | Purpose | -| ----------------------------- | -------------------------------------------------------------------------------------------- | -| `GetTransactionEncryptionKey` | Returns the shared 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. | - -`GetTransactionEncryptionKey` is forwarded to a validator and passed through unchanged: the public key is shared across -the whole validator set, while the attesting signature is specific to the serving validator. Clients verify the -signature against a validator signing key they already trust from the chain and reconstruct the encryption key with +| 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 diff --git a/docs/internal/src/validator.md b/docs/internal/src/validator.md index 4d52b3834b..5e8f5ed3e8 100644 --- a/docs/internal/src/validator.md +++ b/docs/internal/src/validator.md @@ -31,13 +31,12 @@ Once verified, the block is signed and returned to the sender. In addition to its per-validator signing key, every validator is provisioned with the _same_ shared transaction encryption keypair, an Ed25519 key that miden-crypto uses for X25519 key agreement in its IES scheme. Clients will use it to encrypt the private transaction inputs they -submit, so that any validator in the set can decrypt them (submission-path encryption lands in a -follow-up change). +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 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 +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. diff --git a/proto/proto/internal/validator.proto b/proto/proto/internal/validator.proto index 1f5bcbbfb2..c7d7d09b18 100644 --- a/proto/proto/internal/validator.proto +++ b/proto/proto/internal/validator.proto @@ -28,12 +28,12 @@ service Api { // separately as part of recovery. rpc BlockSubscription(BlockSubscriptionRequest) returns (stream BlockSubscriptionResponse) {} - // Returns the shared transaction encryption public key, attested by this validator's own - // signing key. + // 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 attesting signature is - // specific to this validator. + // 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) {} } diff --git a/proto/proto/rpc.proto b/proto/proto/rpc.proto index c6c3750486..3217b28b80 100644 --- a/proto/proto/rpc.proto +++ b/proto/proto/rpc.proto @@ -43,12 +43,14 @@ service Api { // TRANSACTION SUBMISSION ENDPOINTS // -------------------------------------------------------------------------------------------- - // Returns the shared transaction encryption public key, attested by the signing key of the - // validator that served the request. + // 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. // - // The request is forwarded to a validator and the response is passed through unchanged. The - // encryption key is shared across the whole validator set, while the attesting signature is - // specific to the serving validator. + // 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. diff --git a/proto/proto/types/transaction.proto b/proto/proto/types/transaction.proto index 8aef769a8e..53a6dc1d59 100644 --- a/proto/proto/types/transaction.proto +++ b/proto/proto/types/transaction.proto @@ -62,17 +62,61 @@ message TransactionBatch { repeated bytes transaction_inputs = 3; } -// The shared transaction encryption key, attested by a validator. +// IES scheme used for transaction input encryption. // -// The public key is shared across the whole validator set, while the attesting signature is -// specific to the validator that served the request. Verifying the signature against a -// chain-recognized validator signing key proves the encryption key was vouched for by a -// legitimate validator. +// 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 a single `0` byte when no rotation is scheduled, or `1` followed by + // 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 identifier as defined by [miden_protocol::crypto::ies::IesScheme]. + // IES scheme the encryption key belongs to. // - // Currently always `1` (X25519 + XChaCha20-Poly1305). - uint32 scheme = 1; + // 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; @@ -84,21 +128,23 @@ message TransactionEncryptionKey { // key that miden-crypto converts internally for X25519 key agreement). bytes public_key = 3; - // The serving 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`, where `domain_tag` is the ASCII string - // `MIDEN_TX_ENCRYPTION_KEY_ATTESTATION_V1`, the scheme and the length prefixes are encoded as - // 4 bytes little-endian, and the genesis block commitment ties the attestation to one network. - // The canonical construction is `miden_validator::attestation_commitment`. + // 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. // - // Encoded using [miden_serde_utils::Serializable] implementation for - // [miden_protocol::crypto::dsa::ecdsa_k256_keccak::Signature]. Verifiable against the - // validator's signing key committed in block headers. - bytes signature = 4; + // Never set currently; key rotation is not implemented yet. + optional NextTransactionEncryptionKey next_key = 5; - // Reserved for future attestation evidence beyond the validator signature, e.g. a TEE quote, + // 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 5 to 9; + reserved 6 to 9; } // Represents a 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..." From db965839ddfb9fadfff5303a17bc6753678a66d9 Mon Sep 17 00:00:00 2001 From: Juan Munoz Date: Tue, 21 Jul 2026 11:30:53 -0300 Subject: [PATCH 5/7] docs: note breaking changes --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 654e347362..68f38b3120 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - [BREAKING] Updated `miden-protocol` dependencies to use the `next` branch (v0.16). Block and transaction account updates now use the absolute `AccountPatch` representation instead of the relative `AccountDelta`, and the `miden-tx-batch-prover` crate was renamed to `miden-tx-batch` ([#2282](https://github.com/0xMiden/node/pull/2282)). - Added the `GetTransactionEncryptionKey` endpoint to the validator and RPC APIs, returning the shared transaction encryption key attested by the serving validator's signing key. The shared secret is configured on the validator via `--encryption-key.hex` / `MIDEN_VALIDATOR_ENCRYPTION_KEY` and must be identical across the validator set ([#2342](https://github.com/0xMiden/node/pull/2342)). +- [BREAKING] Renamed the validator signing key options: `--key.hex` / `MIDEN_VALIDATOR_KEY` is now `--signing-key.hex` / `MIDEN_VALIDATOR_SIGNING_KEY`, and `--key.kms-id` / `MIDEN_VALIDATOR_KEY_KMS_ID` is now `--signing-key.kms-id` / `MIDEN_VALIDATOR_SIGNING_KEY_KMS_ID` ([#2342](https://github.com/0xMiden/node/pull/2342)). ## v0.15.0 (2026-06-10) From 24aa5f9880ef8ec95f1429a3f4246c397b3e516d Mon Sep 17 00:00:00 2001 From: Juan Munoz Date: Tue, 21 Jul 2026 15:30:01 -0300 Subject: [PATCH 6/7] chore: address PR comments --- .config/cspell.yaml | 1 + CHANGELOG.md | 2 - Cargo.lock | 1 + bin/validator/Cargo.toml | 1 + bin/validator/src/commands/mod.rs | 127 +++++++++++++++--- bin/validator/src/lib.rs | 1 + bin/validator/src/signers/kms.rs | 30 +++++ bin/validator/src/signers/mod.rs | 26 ++-- .../api/get_transaction_encryption_key.rs | 5 +- .../src/network-operator/validator.md | 10 +- proto/proto/types/transaction.proto | 8 +- 11 files changed, 169 insertions(+), 43 deletions(-) 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/CHANGELOG.md b/CHANGELOG.md index 68f38b3120..39e6457240 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,8 +3,6 @@ ## Unreleased - [BREAKING] Updated `miden-protocol` dependencies to use the `next` branch (v0.16). Block and transaction account updates now use the absolute `AccountPatch` representation instead of the relative `AccountDelta`, and the `miden-tx-batch-prover` crate was renamed to `miden-tx-batch` ([#2282](https://github.com/0xMiden/node/pull/2282)). -- Added the `GetTransactionEncryptionKey` endpoint to the validator and RPC APIs, returning the shared transaction encryption key attested by the serving validator's signing key. The shared secret is configured on the validator via `--encryption-key.hex` / `MIDEN_VALIDATOR_ENCRYPTION_KEY` and must be identical across the validator set ([#2342](https://github.com/0xMiden/node/pull/2342)). -- [BREAKING] Renamed the validator signing key options: `--key.hex` / `MIDEN_VALIDATOR_KEY` is now `--signing-key.hex` / `MIDEN_VALIDATOR_SIGNING_KEY`, and `--key.kms-id` / `MIDEN_VALIDATOR_KEY_KMS_ID` is now `--signing-key.kms-id` / `MIDEN_VALIDATOR_SIGNING_KEY_KMS_ID` ([#2342](https://github.com/0xMiden/node/pull/2342)). ## v0.15.0 (2026-06-10) diff --git a/Cargo.lock b/Cargo.lock index df8e389b64..17cdd0b7d4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3784,6 +3784,7 @@ dependencies = [ "anyhow", "aws-config", "aws-sdk-kms", + "base64", "build-rs", "clap", "fs-err", diff --git a/bin/validator/Cargo.toml b/bin/validator/Cargo.toml index da457ed4b8..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 } diff --git a/bin/validator/src/commands/mod.rs b/bin/validator/src/commands/mod.rs index 76249febc2..15c065e167 100644 --- a/bin/validator/src/commands/mod.rs +++ b/bin/validator/src/commands/mod.rs @@ -6,6 +6,7 @@ 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; @@ -26,6 +27,7 @@ const ENV_LISTEN: &str = "MIDEN_VALIDATOR_LISTEN"; 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"; @@ -115,7 +117,7 @@ pub enum ValidatorCommand { env = ENV_SIGNING_KEY, value_name = "VALIDATOR_SIGNING_KEY", default_value = INSECURE_SIGNING_KEY_HEX, - group = "signing_key" + group = "signing_key_source" )] signing_key: String, @@ -126,7 +128,7 @@ pub enum ValidatorCommand { long = "signing-key.kms-id", env = ENV_SIGNING_KEY_KMS_ID, value_name = "VALIDATOR_SIGNING_KEY_KMS_ID", - group = "signing_key" + group = "signing_key_source" )] signing_key_kms_id: Option, @@ -136,13 +138,32 @@ pub enum ValidatorCommand { /// 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 + 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, }, } @@ -182,23 +203,35 @@ impl ValidatorCommand { signing_key_kms_id, sqlite_connection_pool_size, encryption_key, + encryption_key_kms_ciphertext, .. } => { let address = listen; - // 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 for production deployments" - ); - } - - let encryption_key_bytes = hex::decode(encryption_key) - .context("failed to decode the encryption key hex")?; + 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")?; let decrypter: Arc = @@ -273,3 +306,65 @@ impl ValidatorSigningKey { } } } + +// 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/lib.rs b/bin/validator/src/lib.rs index 125015f976..5f5859b9ae 100644 --- a/bin/validator/src/lib.rs +++ b/bin/validator/src/lib.rs @@ -13,6 +13,7 @@ pub use signers::{ TransactionInputDecrypter, ValidatorSigner, attestation_commitment, + decrypt_key_material, }; // CONSTANTS 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 e6f0c4509a..6cb3a476e7 100644 --- a/bin/validator/src/signers/mod.rs +++ b/bin/validator/src/signers/mod.rs @@ -1,5 +1,5 @@ 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::Word; use miden_protocol::crypto::dsa::ecdsa_k256_keccak::{PublicKey, Signature, SigningKey}; @@ -150,10 +150,11 @@ impl TransactionEncryptionKeyInfo { /// 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 a single `0` byte when no rotation is scheduled, or `1` followed by -/// the next key's `scheme || len(key_id) || key_id || len(public_key) || public_key || -/// rotation_block_num` otherwise, so a scheduled rotation cannot be stripped from or injected -/// into an attested response. +/// `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], @@ -171,7 +172,6 @@ pub fn attestation_commitment( + key_id.len() + genesis_commitment.len() + public_key.len() - + 1 + next_key_size, ); payload.extend_from_slice(ATTESTATION_DOMAIN); @@ -179,15 +179,11 @@ pub fn attestation_commitment( 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"); - match next_key { - None => payload.push(0), - Some(next) => { - payload.push(1); - 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()); - }, + 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) } 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 7675234c37..2c85f94af5 100644 --- a/crates/rpc/src/server/api/get_transaction_encryption_key.rs +++ b/crates/rpc/src/server/api/get_transaction_encryption_key.rs @@ -39,9 +39,8 @@ impl proto::server::rpc_api::GetTransactionEncryptionKey for RpcService { 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 { - // A full node configured with a validator connection asks it directly, same as the - // sequencer. RpcMode::Sequencer { validator, .. } | RpcMode::FullNode { validator: Some(validator), .. } => validator .as_ref() @@ -49,8 +48,6 @@ impl proto::server::rpc_api::GetTransactionEncryptionKey for RpcService { .get_transaction_encryption_key(forwarded_request) .await .map(tonic::Response::into_inner), - // A full node without a validator connection relays the request to its upstream RPC, - // which forwards it towards a validator in turn. RpcMode::FullNode { source_rpc, validator: None, .. } => source_rpc .as_ref() .clone() diff --git a/docs/external/src/network-operator/validator.md b/docs/external/src/network-operator/validator.md index 9ae804e619..29032750b2 100644 --- a/docs/external/src/network-operator/validator.md +++ b/docs/external/src/network-operator/validator.md @@ -40,7 +40,13 @@ 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. Production deployments should source it from a secrets manager. The validator logs a warning -at startup if the insecure development default is in use. +every validator in the set. The validator logs a warning at startup if the insecure development default is in use. + +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/proto/proto/types/transaction.proto b/proto/proto/types/transaction.proto index 53a6dc1d59..b4e6d5c782 100644 --- a/proto/proto/types/transaction.proto +++ b/proto/proto/types/transaction.proto @@ -84,10 +84,10 @@ message ValidatorKeyAttestation { // `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 a single `0` byte when no rotation is scheduled, or `1` followed by - // 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`. + // `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; } From 5bed689e4ff95756c7ccd120555be6ed6af0bad8 Mon Sep 17 00:00:00 2001 From: Juan Munoz Date: Tue, 21 Jul 2026 18:30:45 -0300 Subject: [PATCH 7/7] chore: re-export variable --- bin/validator/src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/bin/validator/src/lib.rs b/bin/validator/src/lib.rs index 5f5859b9ae..fce827980e 100644 --- a/bin/validator/src/lib.rs +++ b/bin/validator/src/lib.rs @@ -9,6 +9,7 @@ pub use server::ValidatorServer; pub use signers::{ KmsSigner, LocalX25519TransactionInputDecrypter, + NextEncryptionKeyInfo, TransactionEncryptionKeyInfo, TransactionInputDecrypter, ValidatorSigner,