diff --git a/CHANGELOG.md b/CHANGELOG.md index 39e6457240..68f38b3120 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)). +- [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/bin/validator/src/commands/mod.rs b/bin/validator/src/commands/mod.rs index 15c065e167..bd7985306e 100644 --- a/bin/validator/src/commands/mod.rs +++ b/bin/validator/src/commands/mod.rs @@ -28,6 +28,9 @@ 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_NEXT_ENCRYPTION_KEY: &str = "MIDEN_VALIDATOR_NEXT_ENCRYPTION_KEY"; +const ENV_NEXT_ENCRYPTION_KEY_ROTATION_BLOCK: &str = + "MIDEN_VALIDATOR_NEXT_ENCRYPTION_KEY_ROTATION_BLOCK"; const ENV_GENESIS_CONFIG_FILE: &str = "MIDEN_VALIDATOR_GENESIS_CONFIG_FILE"; const ENV_SQLITE_CONNECTION_POOL_SIZE: &str = "MIDEN_VALIDATOR_SQLITE_CONNECTION_POOL_SIZE"; @@ -164,6 +167,33 @@ pub enum ValidatorCommand { group = "encryption_key_source" )] encryption_key_kms_ciphertext: Option, + + /// Hex-encoded shared secret of the next transaction encryption key, scheduling a key + /// rotation at the block given by `encryption-key.next.rotation-block`. + /// + /// Like the current key, this value and the rotation block must be identical across every + /// validator in the set, and every validator must be reconfigured before the rotation + /// block is reached. Must differ from the current encryption key. + /// + /// Requires `encryption-key.next.rotation-block`. + #[arg( + long = "encryption-key.next.hex", + env = ENV_NEXT_ENCRYPTION_KEY, + value_name = "VALIDATOR_NEXT_ENCRYPTION_KEY", + requires = "encryption_key_rotation_block" + )] + encryption_key_next: Option, + + /// Block number at which the next transaction encryption key replaces the current one. + /// + /// Requires `encryption-key.next.hex`. + #[arg( + long = "encryption-key.next.rotation-block", + env = ENV_NEXT_ENCRYPTION_KEY_ROTATION_BLOCK, + value_name = "ROTATION_BLOCK_NUM", + requires = "encryption_key_next" + )] + encryption_key_rotation_block: Option, }, } @@ -204,18 +234,21 @@ impl ValidatorCommand { sqlite_connection_pool_size, encryption_key, encryption_key_kms_ciphertext, + encryption_key_next, + encryption_key_rotation_block, .. } => { let address = listen; - let encryption_key_bytes = if let Some(ciphertext) = encryption_key_kms_ciphertext { + let encryption_key_hex = 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) + let encryption_key_bytes = miden_validator::decrypt_key_material(ciphertext) .await - .context("failed to decrypt the encryption key with KMS")? + .context("failed to decrypt the encryption key with KMS")?; + hex::encode(encryption_key_bytes) } 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. @@ -229,13 +262,13 @@ impl ValidatorCommand { ); } - hex::decode(encryption_key) - .context("failed to decode the encryption key hex")? + encryption_key }; - let encryption_key = KeyExchangeKey::read_from_bytes(&encryption_key_bytes) - .context("failed to construct the encryption key")?; - let decrypter: Arc = - Arc::new(LocalX25519TransactionInputDecrypter::new(encryption_key)); + let decrypter: Arc = Arc::new(build_decrypter( + &encryption_key_hex, + encryption_key_next.as_deref(), + encryption_key_rotation_block, + )?); let signer = if let Some(kms_key_id) = signing_key_kms_id { ValidatorSigner::new_kms(kms_key_id).await? @@ -266,6 +299,42 @@ impl ValidatorCommand { } } +// TRANSACTION INPUT DECRYPTER CONSTRUCTION +// ================================================================================================ + +/// Builds the transaction input decrypter from the hex-encoded shared secret and, when a rotation +/// is scheduled, the hex-encoded next shared secret and its rotation block. +fn build_decrypter( + encryption_key_hex: &str, + next_key_hex: Option<&str>, + rotation_block: Option, +) -> anyhow::Result { + let encryption_key_bytes = + hex::decode(encryption_key_hex).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 mut decrypter = LocalX25519TransactionInputDecrypter::new(encryption_key); + if let Some(next_key_hex) = next_key_hex { + let rotation_block = + rotation_block.context("encryption-key.next.hex requires a rotation block")?; + let next_key_bytes = + hex::decode(next_key_hex).context("failed to decode the next encryption key hex")?; + if next_key_bytes == encryption_key_bytes { + anyhow::bail!("the next encryption key must differ from the current encryption key"); + } + let next_key = KeyExchangeKey::read_from_bytes(&next_key_bytes) + .context("failed to construct the next encryption key")?; + decrypter = decrypter.with_next_key(next_key, rotation_block); + tracing::info!( + target: LOG_TARGET, + rotation_block, + "Transaction encryption key rotation scheduled" + ); + } + Ok(decrypter) +} + // VALIDATOR SIGNING KEY // ================================================================================================ @@ -367,4 +436,76 @@ mod tests { }; assert_eq!(error.kind(), clap::error::ErrorKind::ArgumentConflict); } + + const NEXT_KEY_HEX: &str = "0303030303030303030303030303030303030303030303030303030303030303"; + + /// The minimal `start` argument list the rotation flags are appended to. + fn start_args() -> Vec<&'static str> { + vec![ + "miden-validator", + "start", + "--listen", + "127.0.0.1:0", + "--data-directory", + "/tmp/validator-data", + ] + } + + /// A rotation is only accepted as a complete pair: the next key without a rotation block and + /// the rotation block without a next key must both be rejected at argument parsing. + #[test] + fn rotation_flags_require_each_other() { + let mut next_only = start_args(); + next_only.extend(["--encryption-key.next.hex", NEXT_KEY_HEX]); + assert!(ValidatorCommand::try_parse_from(next_only).is_err()); + + let mut block_only = start_args(); + block_only.extend(["--encryption-key.next.rotation-block", "100"]); + assert!(ValidatorCommand::try_parse_from(block_only).is_err()); + + let mut both = start_args(); + both.extend([ + "--encryption-key.next.hex", + NEXT_KEY_HEX, + "--encryption-key.next.rotation-block", + "100", + ]); + assert!(ValidatorCommand::try_parse_from(both).is_ok()); + + assert!(ValidatorCommand::try_parse_from(start_args()).is_ok()); + } + + /// A scheduled rotation yields a decrypter announcing the next key at the rotation block. + #[tokio::test] + async fn build_decrypter_schedules_rotation() { + let decrypter = + build_decrypter(INSECURE_ENCRYPTION_KEY_HEX, Some(NEXT_KEY_HEX), Some(42)).unwrap(); + let info = decrypter.encryption_key().await.unwrap(); + let next = info.next_key.expect("rotation must be scheduled"); + assert_eq!(next.rotation_block_num, 42); + + let plain = build_decrypter(INSECURE_ENCRYPTION_KEY_HEX, None, None).unwrap(); + assert!(plain.encryption_key().await.unwrap().next_key.is_none()); + } + + /// Invalid rotation configurations must be rejected: a next key equal to the current one, + /// undecodable hex, key material of the wrong width, and a missing rotation block. + #[test] + fn build_decrypter_rejects_invalid_rotation_config() { + let same_key = build_decrypter( + INSECURE_ENCRYPTION_KEY_HEX, + Some(INSECURE_ENCRYPTION_KEY_HEX), + Some(42), + ); + assert!(same_key.err().unwrap().to_string().contains("must differ")); + + let bad_hex = build_decrypter(INSECURE_ENCRYPTION_KEY_HEX, Some("not hex"), Some(42)); + assert!(bad_hex.err().unwrap().to_string().contains("decode")); + + let short_key = build_decrypter(INSECURE_ENCRYPTION_KEY_HEX, Some("0badf00d"), Some(42)); + assert!(short_key.is_err()); + + let missing_block = build_decrypter(INSECURE_ENCRYPTION_KEY_HEX, Some(NEXT_KEY_HEX), None); + assert!(missing_block.err().unwrap().to_string().contains("rotation 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 d916eb4c18..3e32591559 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 @@ -8,13 +8,15 @@ use crate::COMPONENT; #[tonic::async_trait] impl grpc::server::validator_api::GetTransactionEncryptionKey for ValidatorService { type Input = (); - type Output = grpc::transaction::TransactionEncryptionKey; + type Output = grpc::transaction::TransactionEncryptionKeyResponse; fn decode(request: ()) -> tonic::Result { Ok(request) } - fn encode(output: Self::Output) -> tonic::Result { + fn encode( + output: Self::Output, + ) -> tonic::Result { Ok(output) } @@ -30,22 +32,40 @@ impl grpc::server::validator_api::GetTransactionEncryptionKey for ValidatorServi _metadata: &tonic::metadata::MetadataMap, _extensions: &tonic::codegen::http::Extensions, ) -> tonic::Result { - // Built entirely from state fixed at construction, so the endpoint stays available while a - // backup subscription holds the serve lock. - Ok(grpc::transaction::TransactionEncryptionKey { - scheme: i32::try_from(self.encryption_key_info.scheme) - .expect("scheme identifier must fit in i32"), - key_id: self.encryption_key_info.key_id.clone(), - public_key: self.encryption_key_info.public_key.clone(), - attestations: vec![grpc::transaction::ValidatorKeyAttestation { - validator_public_key: self.signer.public_key().to_bytes(), - signature: self.encryption_key_attestation.to_bytes(), - }], - next_key: self.encryption_key_info.next_key.as_ref().map(|next| { + // Built entirely from state fixed at construction (selected by the in-memory chain tip), so + // the endpoint stays available while a backup subscription holds the serve lock. + let attested = self.effective_encryption_key(); + let validator_public_key = self.signer.public_key().to_bytes(); + let key_message = |scheme: u32, key_id: &[u8], public_key: &[u8], signature: &[u8]| { + grpc::transaction::TransactionEncryptionKey { + scheme: i32::try_from(scheme).expect("scheme identifier must fit in i32"), + key_id: key_id.to_vec(), + public_key: public_key.to_vec(), + attestations: vec![grpc::transaction::ValidatorKeyAttestation { + validator_public_key: validator_public_key.clone(), + signature: signature.to_vec(), + }], + } + }; + Ok(grpc::transaction::TransactionEncryptionKeyResponse { + current_key: Some(key_message( + attested.info.scheme, + &attested.info.key_id, + &attested.info.public_key, + &attested.attestation.to_bytes(), + )), + next_key: attested.info.next_key.as_ref().map(|next| { + let next_attestation = attested + .next_attestation + .as_ref() + .expect("a scheduled next key must carry its attestation"); 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(), + key: Some(key_message( + next.scheme, + &next.key_id, + &next.public_key, + &next_attestation.to_bytes(), + )), 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 d27c12692d..23c77e5eba 100644 --- a/bin/validator/src/server/validator_service/mod.rs +++ b/bin/validator/src/server/validator_service/mod.rs @@ -69,6 +69,48 @@ pub enum ValidatorError { EncryptionKeyAttestationFailed(String), } +// ATTESTED ENCRYPTION KEY +// ================================================================================ + +/// Public metadata of the shared encryption key together with this validator's signatures over the +/// attestation commitments: one for the current key, and one for the scheduled next key when a +/// rotation is announced. +struct AttestedEncryptionKey { + info: TransactionEncryptionKeyInfo, + attestation: Signature, + /// Signature over the scheduled next key's attestation commitment, which binds the rotation + /// block. Present exactly when `info.next_key` is. + next_attestation: Option, +} + +impl AttestedEncryptionKey { + /// Attests `info` with the validator's signing key, binding it to `genesis_commitment`. When a + /// rotation is scheduled, the next key is attested separately with its rotation block bound to + /// the signature. + async fn new( + info: TransactionEncryptionKeyInfo, + signer: &ValidatorSigner, + genesis_commitment: miden_protocol::Word, + ) -> Result { + let attestation = signer + .sign_commitment(info.attestation_commitment(genesis_commitment)) + .await + .map_err(|err| ValidatorError::EncryptionKeyAttestationFailed(err.to_string()))?; + let next_attestation = match &info.next_key { + Some(next) => Some( + signer + .sign_commitment(next.attestation_commitment(genesis_commitment)) + .await + .map_err(|err| { + ValidatorError::EncryptionKeyAttestationFailed(err.to_string()) + })?, + ), + None => None, + }; + Ok(Self { info, attestation, next_attestation }) + } +} + // VALIDATOR SERVICE // ================================================================================ @@ -80,11 +122,13 @@ pub(crate) struct ValidatorService { /// Decrypter for transaction inputs sealed against the shared encryption key. #[expect(dead_code, reason = "used by the submit path in a follow-up PR")] decrypter: Arc, - /// Public metadata of the shared encryption key, fetched once at construction. - encryption_key_info: TransactionEncryptionKeyInfo, - /// Signature by this validator's own signing key over the encryption key attestation - /// commitment, computed once at construction. - encryption_key_attestation: Signature, + /// The attested metadata of the shared encryption key, computed once at construction. Served + /// until the scheduled rotation block (if any) is reached. + encryption_key: AttestedEncryptionKey, + /// The attested post-rotation metadata of the shared encryption key, computed once at + /// construction. Present only when a rotation is scheduled, and served once the chain tip + /// reaches the rotation block. + post_rotation_encryption_key: Option, db: Arc, block_store: BlockStore, /// Enforces mutual exclusion between backup block subscriptions and all other RPCs. Regular @@ -136,8 +180,10 @@ 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. + // Both keys are fixed for the process lifetime, so the attestations are computed once at + // startup. This keeps KMS-backed signers to at most three signing calls: one for the + // current key, and two more when a rotation is scheduled (the next key with its rotation + // block, and the same key re-attested as the current one for after the rotation). let genesis_commitment = db .read("load_genesis_header", |tx| load_block_header(tx, BlockNumber::GENESIS)) .await @@ -148,16 +194,20 @@ impl ValidatorService { .encryption_key() .await .map_err(|err| ValidatorError::EncryptionKeyAttestationFailed(err.to_string()))?; - let encryption_key_attestation = signer - .sign_commitment(encryption_key_info.attestation_commitment(genesis_commitment)) - .await - .map_err(|err| ValidatorError::EncryptionKeyAttestationFailed(err.to_string()))?; + let post_rotation_encryption_key = match encryption_key_info.post_rotation_info() { + Some(info) => { + Some(AttestedEncryptionKey::new(info, &signer, genesis_commitment).await?) + }, + None => None, + }; + let encryption_key = + AttestedEncryptionKey::new(encryption_key_info, &signer, genesis_commitment).await?; Ok(Self { signer, decrypter, - encryption_key_info, - encryption_key_attestation, + encryption_key, + post_rotation_encryption_key, serve_lock: Arc::new(tokio::sync::RwLock::new(())), db: db.into(), block_store, @@ -168,6 +218,20 @@ impl ValidatorService { }) } + /// Returns the attested encryption key metadata effective at the current chain tip: the + /// post-rotation key once the tip has reached the scheduled rotation block, and the current key + /// (announcing the scheduled rotation, if any) before that. + fn effective_encryption_key(&self) -> &AttestedEncryptionKey { + match (&self.post_rotation_encryption_key, &self.encryption_key.info.next_key) { + (Some(post), Some(next)) + if self.committed_tip.borrow().as_u32() >= next.rotation_block_num => + { + post + }, + _ => &self.encryption_key, + } + } + /// Validates a proposed block by checking: /// 1. All transactions have been previously validated by this validator. /// 2. The block header can be successfully built from the proposed block. diff --git a/bin/validator/src/server/validator_service/tests.rs b/bin/validator/src/server/validator_service/tests.rs index 15e0f57e84..b24dbb7b81 100644 --- a/bin/validator/src/server/validator_service/tests.rs +++ b/bin/validator/src/server/validator_service/tests.rs @@ -14,7 +14,7 @@ use miden_tx::utils::serde::{Deserializable, Serializable}; use super::{ValidatorError, ValidatorService}; use crate::db::{load_chain_tip, setup, upsert_block_header}; -use crate::signers::{NextEncryptionKeyInfo, attestation_commitment}; +use crate::signers::{TransactionEncryptionKeyInfo, attestation_commitment}; use crate::{LocalX25519TransactionInputDecrypter, TransactionInputDecrypter, ValidatorSigner}; // TEST HELPERS @@ -31,6 +31,46 @@ fn test_decrypter() -> LocalX25519TransactionInputDecrypter { LocalX25519TransactionInputDecrypter::new(key) } +/// The next shared transaction encryption secret, used by the rotation tests. +const TEST_NEXT_ENCRYPTION_SECRET: [u8; 32] = [4u8; 32]; + +/// Creates a [`LocalX25519TransactionInputDecrypter`] with a key rotation to the next test secret +/// scheduled at `rotation_block_num`. +fn test_rotating_decrypter(rotation_block_num: u32) -> LocalX25519TransactionInputDecrypter { + let next_key = KeyExchangeKey::read_from_bytes(&TEST_NEXT_ENCRYPTION_SECRET) + .expect("next test secret should be a valid key exchange key"); + test_decrypter().with_next_key(next_key, rotation_block_num) +} + +/// The public metadata of the scheduled next test secret, as if it were a standalone current key. +async fn next_test_key_info() -> TransactionEncryptionKeyInfo { + let next_key = KeyExchangeKey::read_from_bytes(&TEST_NEXT_ENCRYPTION_SECRET) + .expect("next test secret should be a valid key exchange key"); + LocalX25519TransactionInputDecrypter::new(next_key) + .encryption_key() + .await + .expect("next key info should be available") +} + +/// Returns the current key carried by the response. +fn current_key( + response: &proto::transaction::TransactionEncryptionKeyResponse, +) -> &proto::transaction::TransactionEncryptionKey { + response.current_key.as_ref().expect("the response must carry a current key") +} + +/// Asserts `key` matches `info`'s public key material, and returns the key's scheme. +fn assert_serves_key( + key: &proto::transaction::TransactionEncryptionKey, + info: &TransactionEncryptionKeyInfo, +) -> u32 { + let scheme = u32::try_from(key.scheme).expect("scheme must be non-negative"); + assert_eq!(scheme, info.scheme); + assert_eq!(key.key_id, info.key_id); + assert_eq!(key.public_key, info.public_key); + scheme +} + /// Test harness that wraps a [`Validator`] and tracks the chain MMR state needed to construct valid /// [`ProposedBlock`]s. struct TestValidator { @@ -46,6 +86,11 @@ impl TestValidator { /// Creates a correctly configured [`ValidatorService`]: the validator signs blocks with the /// same key that is designated as the `validator_key` in the genesis block. async fn new() -> Self { + Self::with_decrypter(test_decrypter()).await + } + + /// Creates a correctly configured [`ValidatorService`] provisioned with the given decrypter. + async fn with_decrypter(decrypter: LocalX25519TransactionInputDecrypter) -> Self { let key = random_secret_key(); let signer = ValidatorSigner::new_local(key.clone()); let (temp_dir, db, block_store, genesis_header) = setup_db_with_genesis(&key).await; @@ -53,7 +98,7 @@ impl TestValidator { Self { server: ValidatorService::new( signer, - std::sync::Arc::new(test_decrypter()), + std::sync::Arc::new(decrypter), db, block_store, 0, @@ -118,7 +163,7 @@ impl TestValidator { /// Calls the `get_transaction_encryption_key` endpoint on the validator server. async fn call_get_transaction_encryption_key( &self, - ) -> proto::transaction::TransactionEncryptionKey { + ) -> proto::transaction::TransactionEncryptionKeyResponse { validator_api::GetTransactionEncryptionKey::full(&self.server, tonic::Request::new(())) .await .expect("encryption key should always be available") @@ -744,18 +789,15 @@ async fn transaction_encryption_key_is_attested() { // 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 key = current_key(&response); 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 scheme = assert_serves_key(key, &info); - let commitment = - attestation_commitment(scheme, &response.key_id, genesis, &response.public_key, None); + let commitment = attestation_commitment(scheme, &key.key_id, genesis, &key.public_key, None); assert_eq!(commitment, info.attestation_commitment(genesis)); - let [attestation] = response.attestations.as_slice() else { + let [attestation] = key.attestations.as_slice() else { panic!("response must carry exactly the serving validator's attestation"); }; assert_eq!( @@ -780,12 +822,14 @@ async fn shared_key_is_attested_per_validator() { let response_a = tv_a.call_get_transaction_encryption_key().await; let response_b = tv_b.call_get_transaction_encryption_key().await; + let key_a = current_key(&response_a); + let key_b = current_key(&response_b); - assert_eq!(response_a.scheme, response_b.scheme); - assert_eq!(response_a.key_id, response_b.key_id); - assert_eq!(response_a.public_key, response_b.public_key); + assert_eq!(key_a.scheme, key_b.scheme); + assert_eq!(key_a.key_id, key_b.key_id); + assert_eq!(key_a.public_key, key_b.public_key); assert_ne!( - response_a.attestations[0].signature, response_b.attestations[0].signature, + key_a.attestations[0].signature, key_b.attestations[0].signature, "each validator must attest with its own signing key", ); } @@ -797,47 +841,29 @@ async fn tampered_attestation_fails_verification() { let tv = TestValidator::new().await; let genesis = tv.chain_tip.commitment(); let response = tv.call_get_transaction_encryption_key().await; - let signature = Signature::read_from_bytes(&response.attestations[0].signature).unwrap(); + let key = current_key(&response); + let signature = Signature::read_from_bytes(&key.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 scheme = u32::try_from(key.scheme).expect("scheme must be non-negative"); - let mut tampered_public_key = response.public_key.clone(); + let mut tampered_public_key = key.public_key.clone(); tampered_public_key[0] ^= 0x01; - let mut tampered_key_id = response.key_id.clone(); + let mut tampered_key_id = key.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 mut extended_key_id = key.key_id.clone(); + extended_key_id.push(key.public_key[0]); let tampered_genesis = Word::try_from([9u64, 9, 9, 9]).unwrap(); - // Injecting a scheduled rotation into a response attested without one must also break the - // signature. - let injected_next_key = NextEncryptionKeyInfo { - scheme, - key_id: response.key_id.clone(), - public_key: response.public_key.clone(), - rotation_block_num: 100, - }; let tampered_commitments = [ - attestation_commitment(scheme + 1, &response.key_id, genesis, &response.public_key, None), - attestation_commitment(scheme, &tampered_key_id, genesis, &response.public_key, None), - attestation_commitment(scheme, &extended_key_id, genesis, &response.public_key[1..], None), - attestation_commitment(scheme, &response.key_id, genesis, &tampered_public_key, None), - attestation_commitment( - scheme, - &response.key_id, - tampered_genesis, - &response.public_key, - None, - ), - attestation_commitment( - scheme, - &response.key_id, - genesis, - &response.public_key, - Some(&injected_next_key), - ), + attestation_commitment(scheme + 1, &key.key_id, genesis, &key.public_key, None), + attestation_commitment(scheme, &tampered_key_id, genesis, &key.public_key, None), + attestation_commitment(scheme, &extended_key_id, genesis, &key.public_key[1..], None), + attestation_commitment(scheme, &key.key_id, genesis, &tampered_public_key, None), + attestation_commitment(scheme, &key.key_id, tampered_genesis, &key.public_key, None), + // A current-key attestation must not be reusable as a next-key attestation. + attestation_commitment(scheme, &key.key_id, genesis, &key.public_key, Some(100)), ]; for commitment in tampered_commitments { assert!( @@ -847,6 +873,113 @@ async fn tampered_attestation_fails_verification() { } } +/// Before the rotation block, the endpoint keeps serving the current key and announces the +/// scheduled next key. The next key carries its own attestation, which verifies over the next key's +/// transcript extended with the rotation block, recomputed entirely from the response. +#[tokio::test] +async fn scheduled_rotation_is_announced_and_attested() { + let tv = TestValidator::with_decrypter(test_rotating_decrypter(2)).await; + let genesis = tv.chain_tip.commitment(); + let response = tv.call_get_transaction_encryption_key().await; + + // The current key material is unchanged by the scheduled rotation. + let current_info = + test_decrypter().encryption_key().await.expect("key info should be available"); + assert_serves_key(current_key(&response), ¤t_info); + + // The announcement carries the next key's public material and the rotation block. + let next = response.next_key.as_ref().expect("a scheduled rotation must be announced"); + let next_key = next.key.as_ref().expect("the announcement must carry the next key"); + let next_info = next_test_key_info().await; + let next_scheme = assert_serves_key(next_key, &next_info); + assert_eq!(next.rotation_block_num, 2); + + // The next key's attestation verifies over its transcript extended with the rotation block, and + // breaks if the rotation block is altered or dropped. + let signature = Signature::read_from_bytes(&next_key.attestations[0].signature).unwrap(); + let verifies_with = |rotation_block_num: Option| { + let commitment = attestation_commitment( + next_scheme, + &next_key.key_id, + genesis, + &next_key.public_key, + rotation_block_num, + ); + signature.verify(commitment, &tv.server.signer.public_key()) + }; + assert!( + verifies_with(Some(next.rotation_block_num)), + "attestation must verify over the rotation block" + ); + assert!(!verifies_with(None), "a next-key attestation must not verify as a current key"); + assert!( + !verifies_with(Some(next.rotation_block_num + 1)), + "an altered rotation block must not verify" + ); +} + +/// Once the chain tip reaches the rotation block, the endpoint serves the announced key as the +/// current one, with no further rotation scheduled and an attestation that verifies over the +/// promoted key. +#[tokio::test] +async fn rotation_takes_effect_at_rotation_block() { + let mut tv = TestValidator::with_decrypter(test_rotating_decrypter(1)).await; + let genesis = tv.chain_tip.commitment(); + + // At the genesis tip the rotation is still pending. + let before = tv.call_get_transaction_encryption_key().await; + let announced = before + .next_key + .as_ref() + .expect("the rotation must still be announced before its block"); + let announced_key = announced.key.as_ref().expect("the announcement must carry the next key"); + + // Advance the chain tip to the rotation block. + tv.apply_empty_block().await; + + let after = tv.call_get_transaction_encryption_key().await; + let after_key = current_key(&after); + let scheme = assert_serves_key(after_key, &next_test_key_info().await); + assert!(after.next_key.is_none(), "no further rotation is scheduled after the flip"); + + // The post-rotation attestation verifies over the promoted key as a current key, while the + // next-key attestation announced before the rotation is not replayable for it. + let commitment = + attestation_commitment(scheme, &after_key.key_id, genesis, &after_key.public_key, None); + let validator_key = tv.server.signer.public_key(); + let signature = Signature::read_from_bytes(&after_key.attestations[0].signature).unwrap(); + let announced_signature = + Signature::read_from_bytes(&announced_key.attestations[0].signature).unwrap(); + assert!( + signature.verify(commitment, &validator_key), + "the post-rotation attestation must verify over the promoted key", + ); + assert!( + !announced_signature.verify(commitment, &validator_key), + "the next-key attestation must not verify over the promoted key", + ); + + // Past the rotation block, the promoted key keeps being served. + tv.apply_empty_block().await; + let later = tv.call_get_transaction_encryption_key().await; + let later_key = current_key(&later); + assert_eq!(later_key.key_id, after_key.key_id); + assert_eq!(later_key.public_key, after_key.public_key); + assert!(later.next_key.is_none()); +} + +/// A validator that starts (or restarts) after the rotation block serves the promoted key +/// immediately, so a stale `next` configuration does not resurrect the old key. +#[tokio::test] +async fn past_rotation_block_serves_promoted_key_at_startup() { + // The rotation block equals the genesis tip, so the rotation is already effective. + let tv = TestValidator::with_decrypter(test_rotating_decrypter(0)).await; + let response = tv.call_get_transaction_encryption_key().await; + + assert_serves_key(current_key(&response), &next_test_key_info().await); + assert!(response.next_key.is_none()); +} + /// 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. @@ -858,7 +991,7 @@ async fn response_key_seals_for_the_validator_set() { let tv = TestValidator::new().await; let response = tv.call_get_transaction_encryption_key().await; - let public_key = EncryptionPublicKey::read_from_bytes(&response.public_key) + let public_key = EncryptionPublicKey::read_from_bytes(¤t_key(&response).public_key) .expect("response public key should deserialize"); let sealing_key = SealingKey::X25519XChaCha20Poly1305(public_key); @@ -897,7 +1030,7 @@ async fn encryption_key_available_during_backup() { // `call_get_transaction_encryption_key` panics on rejection, so completing proves availability // during the backup. let response = tv.call_get_transaction_encryption_key().await; - assert!(!response.public_key.is_empty()); + assert!(!current_key(&response).public_key.is_empty()); drop(stream); } diff --git a/bin/validator/src/signers/mod.rs b/bin/validator/src/signers/mod.rs index 6cb3a476e7..e09357c00a 100644 --- a/bin/validator/src/signers/mod.rs +++ b/bin/validator/src/signers/mod.rs @@ -104,8 +104,7 @@ 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. + /// The next encryption key when a rotation is scheduled. pub next_key: Option, } @@ -123,17 +122,45 @@ pub struct NextEncryptionKeyInfo { pub rotation_block_num: u32, } +impl NextEncryptionKeyInfo { + /// Returns the commitment signed by a validator to attest the encryption key as the scheduled + /// next one, binding the rotation block to the signature. + pub fn attestation_commitment(&self, genesis_commitment: Word) -> Word { + attestation_commitment( + self.scheme, + &self.key_id, + genesis_commitment, + &self.public_key, + Some(self.rotation_block_num), + ) + } +} + impl TransactionEncryptionKeyInfo { - /// Returns the commitment signed by a validator to attest the encryption key. + /// Returns the commitment signed by a validator to attest the encryption key as the current + /// one. pub fn attestation_commitment(&self, genesis_commitment: Word) -> Word { attestation_commitment( self.scheme, &self.key_id, genesis_commitment, &self.public_key, - self.next_key.as_ref(), + None, ) } + + /// Returns the key metadata that becomes effective once the scheduled rotation block is + /// reached: the next key promoted to the current one, with no further rotation scheduled. + /// + /// Returns `None` when no rotation is scheduled. + pub fn post_rotation_info(&self) -> Option { + self.next_key.as_ref().map(|next| Self { + scheme: next.scheme, + key_id: next.key_id.clone(), + public_key: next.public_key.clone(), + next_key: None, + }) + } } /// Computes the attestation commitment over explicit wire-format fields. @@ -143,47 +170,44 @@ 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 || 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. +/// genesis_commitment || len(public_key) || public_key || rotation_transcript`, binding every +/// attested field to the signature. The scheme, the rotation block number, and the length +/// prefixes are encoded as 4 bytes little-endian, and the length prefixes on the variable-width +/// fields ensure no two field combinations map to the same payload. Including the genesis +/// commitment ties the attestation to one chain, so it cannot be replayed on another network +/// whose validator reuses the same signing key. /// -/// `next_key_transcript` is empty when no rotation is scheduled, or the next key's `scheme || -/// len(key_id) || key_id || len(public_key) || public_key || rotation_block_num` otherwise. All -/// fields ahead of it are fixed-width or length-prefixed, so the transcript's presence and -/// content are unambiguous and a scheduled rotation cannot be stripped from or injected into an -/// attested response. +/// `rotation_transcript` is a single `0` byte when the key is attested as the current one +/// (`rotation_block_num` is `None`), or `1` followed by the rotation block number when the key is +/// attested as a scheduled next key, so a next-key attestation cannot be replayed as a current +/// key nor its rotation block altered. pub fn attestation_commitment( scheme: u32, key_id: &[u8], genesis_commitment: Word, public_key: &[u8], - next_key: Option<&NextEncryptionKeyInfo>, + rotation_block_num: Option, ) -> Word { let genesis_commitment = genesis_commitment.to_bytes(); - let next_key_size = next_key - .map(|next| 3 * size_of::() + next.key_id.len() + next.public_key.len()) - .unwrap_or_default(); let mut payload = Vec::with_capacity( ATTESTATION_DOMAIN.len() - + 3 * size_of::() + + 4 * size_of::() + key_id.len() + genesis_commitment.len() + public_key.len() - + next_key_size, + + 1, ); payload.extend_from_slice(ATTESTATION_DOMAIN); payload.extend_from_slice(&scheme.to_le_bytes()); extend_with_length_prefixed(&mut payload, key_id, "key id"); payload.extend_from_slice(&genesis_commitment); extend_with_length_prefixed(&mut payload, public_key, "public key"); - if let Some(next) = next_key { - payload.extend_from_slice(&next.scheme.to_le_bytes()); - extend_with_length_prefixed(&mut payload, &next.key_id, "next key id"); - extend_with_length_prefixed(&mut payload, &next.public_key, "next public key"); - payload.extend_from_slice(&next.rotation_block_num.to_le_bytes()); + match rotation_block_num { + None => payload.push(0), + Some(rotation_block_num) => { + payload.push(1); + payload.extend_from_slice(&rotation_block_num.to_le_bytes()); + }, } miden_protocol::Hasher::hash(&payload) } @@ -201,8 +225,20 @@ fn extend_with_length_prefixed(payload: &mut Vec, field: &[u8], name: &str) } /// [`TransactionInputDecrypter`] backed by a locally provisioned X25519 shared secret. +/// +/// When a key rotation is scheduled (see [`Self::with_next_key`]) the decrypter also holds the +/// next shared secret. The next key is announced through [`TransactionEncryptionKeyInfo::next_key`] +/// ahead of the rotation, and submissions sealed against either key can be unsealed, so +/// submissions sealed just before the rotation block still decrypt after it. pub struct LocalX25519TransactionInputDecrypter { secret_key: KeyExchangeKey, + next_key: Option, +} + +/// The next shared secret and its scheduled activation block. +struct NextKey { + secret_key: KeyExchangeKey, + rotation_block_num: u32, } impl LocalX25519TransactionInputDecrypter { @@ -211,7 +247,18 @@ impl LocalX25519TransactionInputDecrypter { /// Constructs a decrypter from a locally provisioned shared secret. pub fn new(secret_key: KeyExchangeKey) -> Self { - Self { secret_key } + Self { secret_key, next_key: None } + } + + /// Schedules a key rotation: `secret_key` replaces the current shared secret at + /// `rotation_block_num`. + /// + /// Like the current secret, the next secret and the rotation block must be identical across + /// every validator in the set. + #[must_use] + pub fn with_next_key(mut self, secret_key: KeyExchangeKey, rotation_block_num: u32) -> Self { + self.next_key = Some(NextKey { secret_key, rotation_block_num }); + self } /// Returns the wire representation of [`Self::SCHEME`]. @@ -227,7 +274,7 @@ impl LocalX25519TransactionInputDecrypter { /// 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() + key_id_of(&self.public_key()) } /// Returns the sealing key that clients use to encrypt messages to the validator set. @@ -235,6 +282,33 @@ impl LocalX25519TransactionInputDecrypter { pub fn sealing_key(&self) -> SealingKey { SealingKey::X25519XChaCha20Poly1305(self.public_key()) } + + /// Returns the sealing key of the scheduled next encryption key, if any. + #[cfg(test)] + pub fn next_sealing_key(&self) -> Option { + self.next_key + .as_ref() + .map(|next| SealingKey::X25519XChaCha20Poly1305(next.secret_key.public_key())) + } + + /// Attempts to unseal `message` with `secret_key`. + fn unseal_with( + secret_key: &KeyExchangeKey, + message: SealedMessage, + associated_data: &[u8], + ) -> anyhow::Result> { + use anyhow::Context; + + UnsealingKey::X25519XChaCha20Poly1305(secret_key.clone()) + .unseal_bytes_with_associated_data(message, associated_data) + .context("failed to unseal the transaction inputs") + } +} + +/// Returns the opaque identifier of an encryption key: the first 4 bytes of the public key +/// commitment. +fn key_id_of(public_key: &EncryptionPublicKey) -> Vec { + public_key.to_commitment().to_bytes()[..4].to_vec() } #[tonic::async_trait] @@ -244,7 +318,15 @@ impl TransactionInputDecrypter for LocalX25519TransactionInputDecrypter { scheme: Self::scheme_id(), key_id: self.key_id(), public_key: self.public_key().to_bytes(), - next_key: None, + next_key: self.next_key.as_ref().map(|next| { + let public_key = next.secret_key.public_key(); + NextEncryptionKeyInfo { + scheme: Self::scheme_id(), + key_id: key_id_of(&public_key), + public_key: public_key.to_bytes(), + rotation_block_num: next.rotation_block_num, + } + }), }) } @@ -257,9 +339,16 @@ impl TransactionInputDecrypter for LocalX25519TransactionInputDecrypter { let message = SealedMessage::read_from_bytes(ciphertext) .context("failed to deserialize the sealed message")?; - UnsealingKey::X25519XChaCha20Poly1305(self.secret_key.clone()) - .unseal_bytes_with_associated_data(message, associated_data) - .context("failed to unseal the transaction inputs") + // Submissions may be sealed against the current key or, around a scheduled rotation, + // against the next key. The sealed message does not identify its key, so try the current + // key first and fall back to the next one. + match Self::unseal_with(&self.secret_key, message.clone(), associated_data) { + Ok(plaintext) => Ok(plaintext), + Err(err) => match &self.next_key { + Some(next) => Self::unseal_with(&next.secret_key, message, associated_data), + None => Err(err), + }, + } } } @@ -302,6 +391,67 @@ mod tests { assert_ne!(info_a.attestation_commitment(genesis), info_b.attestation_commitment(genesis)); } + /// Around a scheduled rotation, messages sealed against either the current or the next key must + /// decrypt, while an unrelated key must still be rejected. + #[tokio::test] + async fn decrypts_messages_sealed_against_either_key() { + let mut rng = rng(); + let next_secret = KeyExchangeKey::read_from_bytes(&[8u8; 32]).unwrap(); + let decrypter = decrypter_from(&[7u8; 32]).with_next_key(next_secret, 42); + let plaintext = b"transaction inputs"; + let associated_data = b"scheme|key_id|chain|tx"; + + let sealed_current = decrypter + .sealing_key() + .seal_bytes_with_associated_data(&mut rng, plaintext, associated_data) + .unwrap() + .to_bytes(); + assert_eq!( + decrypter + .decrypt_transaction_inputs(&sealed_current, associated_data) + .await + .unwrap() + .as_slice(), + plaintext, + ); + + let sealed_next = decrypter + .next_sealing_key() + .expect("a scheduled rotation must expose the next sealing key") + .seal_bytes_with_associated_data(&mut rng, plaintext, associated_data) + .unwrap() + .to_bytes(); + assert_eq!( + decrypter + .decrypt_transaction_inputs(&sealed_next, associated_data) + .await + .unwrap() + .as_slice(), + plaintext, + ); + + // A message sealed against an unrelated key must be rejected by both keyring entries. + let sealed_other = decrypter_from(&[9u8; 32]) + .sealing_key() + .seal_bytes_with_associated_data(&mut rng, plaintext, associated_data) + .unwrap() + .to_bytes(); + assert!( + decrypter + .decrypt_transaction_inputs(&sealed_other, associated_data) + .await + .is_err() + ); + + // Mismatched associated data must fail for either key. + assert!( + decrypter + .decrypt_transaction_inputs(&sealed_next, b"wrong associated data") + .await + .is_err() + ); + } + /// 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] diff --git a/crates/rpc/src/server/api/get_transaction_encryption_key.rs b/crates/rpc/src/server/api/get_transaction_encryption_key.rs index 2c85f94af5..d5000f0dbe 100644 --- a/crates/rpc/src/server/api/get_transaction_encryption_key.rs +++ b/crates/rpc/src/server/api/get_transaction_encryption_key.rs @@ -8,13 +8,15 @@ use crate::{COMPONENT, LOG_TARGET}; #[tonic::async_trait] impl proto::server::rpc_api::GetTransactionEncryptionKey for RpcService { type Input = (); - type Output = proto::transaction::TransactionEncryptionKey; + type Output = proto::transaction::TransactionEncryptionKeyResponse; fn decode(request: ()) -> tonic::Result { Ok(request) } - fn encode(output: Self::Output) -> tonic::Result { + fn encode( + output: Self::Output, + ) -> tonic::Result { Ok(output) } diff --git a/crates/rpc/src/tests.rs b/crates/rpc/src/tests.rs index af93ea1b1d..4d3d24bea5 100644 --- a/crates/rpc/src/tests.rs +++ b/crates/rpc/src/tests.rs @@ -676,7 +676,7 @@ async fn start_source_rpc( /// other RPC. #[derive(Clone)] struct FixedValidator { - encryption_key: proto::transaction::TransactionEncryptionKey, + encryption_key: proto::transaction::TransactionEncryptionKeyResponse, call_count: Arc, last_accept: Arc>>, } @@ -684,13 +684,15 @@ struct FixedValidator { #[tonic::async_trait] impl validator_api::GetTransactionEncryptionKey for FixedValidator { type Input = (); - type Output = proto::transaction::TransactionEncryptionKey; + type Output = proto::transaction::TransactionEncryptionKeyResponse; fn decode(request: ()) -> tonic::Result { Ok(request) } - fn encode(output: Self::Output) -> tonic::Result { + fn encode( + output: Self::Output, + ) -> tonic::Result { Ok(output) } @@ -807,7 +809,7 @@ impl validator_api::BlockSubscription for FixedValidator { /// Serves a [`FixedValidator`] on an ephemeral port and returns a connected client together with /// the stub's call counter and the last ACCEPT header it observed. async fn start_validator( - encryption_key: proto::transaction::TransactionEncryptionKey, + encryption_key: proto::transaction::TransactionEncryptionKeyResponse, ) -> (ValidatorClient, Arc, Arc>>) { let listener = TcpListener::bind("127.0.0.1:0").await.expect("Failed to bind validator"); let addr = listener.local_addr().expect("Failed to get validator address"); @@ -840,19 +842,27 @@ async fn start_validator( /// A fixed transaction encryption key response for forwarding tests. The values only need to /// survive the passthrough unchanged. -fn test_encryption_key() -> proto::transaction::TransactionEncryptionKey { - proto::transaction::TransactionEncryptionKey { - scheme: proto::transaction::IesScheme::X25519Xchacha20Poly1305 as i32, - key_id: vec![0xDE, 0xAD, 0xBE, 0xEF], - public_key: vec![7; 32], - attestations: vec![proto::transaction::ValidatorKeyAttestation { - validator_public_key: vec![8; 33], - signature: vec![9; 65], - }], - next_key: Some(proto::transaction::NextTransactionEncryptionKey { +fn test_encryption_key() -> proto::transaction::TransactionEncryptionKeyResponse { + proto::transaction::TransactionEncryptionKeyResponse { + current_key: Some(proto::transaction::TransactionEncryptionKey { scheme: proto::transaction::IesScheme::X25519Xchacha20Poly1305 as i32, - key_id: vec![0xFE, 0xED], - public_key: vec![6; 32], + key_id: vec![0xDE, 0xAD, 0xBE, 0xEF], + public_key: vec![7; 32], + attestations: vec![proto::transaction::ValidatorKeyAttestation { + validator_public_key: vec![8; 33], + signature: vec![9; 65], + }], + }), + next_key: Some(proto::transaction::NextTransactionEncryptionKey { + key: Some(proto::transaction::TransactionEncryptionKey { + scheme: proto::transaction::IesScheme::X25519Xchacha20Poly1305 as i32, + key_id: vec![0xFE, 0xED], + public_key: vec![6; 32], + attestations: vec![proto::transaction::ValidatorKeyAttestation { + validator_public_key: vec![8; 33], + signature: vec![10; 65], + }], + }), rotation_block_num: 42, }), } diff --git a/docs/external/src/network-operator/validator.md b/docs/external/src/network-operator/validator.md index 29032750b2..57f6585a22 100644 --- a/docs/external/src/network-operator/validator.md +++ b/docs/external/src/network-operator/validator.md @@ -49,4 +49,12 @@ so its AWS identity needs that permission on the wrapping key. Note that, unlike encryption key is held in validator memory: AWS KMS cannot perform X25519 key agreement itself, so envelope encryption is the supported provisioning path. +To rotate the shared encryption key, restart every validator with `--encryption-key.next.hex` (or +`MIDEN_VALIDATOR_NEXT_ENCRYPTION_KEY`) set to the new shared secret and `--encryption-key.next.rotation-block` (or +`MIDEN_VALIDATOR_NEXT_ENCRYPTION_KEY_ROTATION_BLOCK`) set to the block number at which the new key takes effect. The +next key must differ from the current one. All validators must be configured with the same next key and rotation block +before the rotation block is reached. Until that block, validators keep serving the current key and announce the +upcoming one so clients can prepare. From that block on, they serve the new key as the current one. After the rotation, +deployments should eventually move the new secret to `--encryption-key.hex` and drop the `next` options. + Use `miden-validator start --help` for the complete current option list. diff --git a/docs/external/src/rpc/public-api.md b/docs/external/src/rpc/public-api.md index 5e92b9b5e3..eb87ac0c58 100644 --- a/docs/external/src/rpc/public-api.md +++ b/docs/external/src/rpc/public-api.md @@ -41,11 +41,12 @@ grpcurl rpc.testnet.miden.io:443 describe rpc.Api The public key returned by `GetTransactionEncryptionKey` is shared across the whole validator set, while each attestation is specific to one validator (currently the response carries a single attestation). Clients verify an attestation against a validator signing key they already trust from the chain and reconstruct the encryption key with -miden-crypto. The exact attestation payload is documented on the `TransactionEncryptionKey` proto message. Note that -this scheme does not hide transaction inputs from holders of the shared encryption secret (currently the network -operator and every validator) and provides no forward secrecy. The attestation proves which validator vouched for the -key but does not prove freshness: after a key rotation, a replayed older signed key still verifies until a chain or -epoch rule for freshness exists. +miden-crypto. When a key rotation is scheduled, the response also announces the next key and its rotation block in the +`next_key` field. The next key carries its own attestations, whose commitment covers the rotation block. The exact +attestation payload is documented on the `ValidatorKeyAttestation` proto message. Note that this scheme does not hide +transaction inputs from holders of the shared encryption secret (currently the network operator and every validator) and +provides no forward secrecy. The attestation proves which validator vouched for the key but does not prove freshness: +after a key rotation, a replayed older signed key still verifies until a chain or epoch rule for freshness exists. Write requests must identify the target network with the `genesis` parameter in the `Accept` header: diff --git a/docs/internal/src/validator.md b/docs/internal/src/validator.md index 5e8f5ed3e8..e4a905f19e 100644 --- a/docs/internal/src/validator.md +++ b/docs/internal/src/validator.md @@ -41,6 +41,14 @@ signatures, and the genesis commitment so an attestation cannot replay across ne signature proves to clients that a chain-recognized validator vouches for the key, so the key can be served through an untrusted RPC. +The key can be rotated. Operators configure every validator with the same next shared secret and a +rotation block number, and the endpoint announces the upcoming key in the `next_key` field ahead of +the rotation. The announcement is covered by the attestation commitment, so it cannot be stripped +or altered without invalidating the signatures. Once the chain tip reaches the rotation block, the +validator serves the announced key as the current one under a fresh attestation computed at +startup. The decrypter keeps both secrets, so submissions sealed against either key around the +rotation still decrypt. + 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 c7d7d09b18..618b65bf87 100644 --- a/proto/proto/internal/validator.proto +++ b/proto/proto/internal/validator.proto @@ -34,7 +34,7 @@ service Api { // The encryption key is shared across the whole validator set, so the returned public key is // identical regardless of which validator serves the request. The attestation carried in the // response is specific to this validator. - rpc GetTransactionEncryptionKey(google.protobuf.Empty) returns (transaction.TransactionEncryptionKey) {} + rpc GetTransactionEncryptionKey(google.protobuf.Empty) returns (transaction.TransactionEncryptionKeyResponse) {} } // BLOCK SUBSCRIPTION diff --git a/proto/proto/rpc.proto b/proto/proto/rpc.proto index 3217b28b80..0c2ff32a20 100644 --- a/proto/proto/rpc.proto +++ b/proto/proto/rpc.proto @@ -51,7 +51,7 @@ service Api { // 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) {} + rpc GetTransactionEncryptionKey(google.protobuf.Empty) returns (transaction.TransactionEncryptionKeyResponse) {} // Submits proven transaction to the Miden network. Returns the node's current block height. rpc SubmitProvenTx(transaction.ProvenTransaction) returns (blockchain.BlockNumber) {} diff --git a/proto/proto/types/transaction.proto b/proto/proto/types/transaction.proto index b4e6d5c782..b8bcda7bdd 100644 --- a/proto/proto/types/transaction.proto +++ b/proto/proto/types/transaction.proto @@ -80,33 +80,17 @@ message ValidatorKeyAttestation { // 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 + // public_key || rotation_transcript`, where `domain_tag` is the ASCII string // `MIDEN_TX_ENCRYPTION_KEY_ATTESTATION_V1`, the scheme, the rotation block number, and the // length prefixes are encoded as 4 bytes little-endian, and the genesis block commitment ties // the attestation to one network. - // `next_key_transcript` is empty when no rotation is scheduled, or the next key's `scheme || - // len(key_id) || key_id || len(public_key) || public_key || rotation_block_num` otherwise, - // binding any scheduled rotation to the signature. The canonical construction is + // `rotation_transcript` is a single `0` byte when the key is attested as the current key, or + // `1` followed by `rotation_block_num` when the key is attested as a scheduled next key, + // binding the rotation block 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 @@ -128,23 +112,41 @@ message TransactionEncryptionKey { // key that miden-crypto converts internally for X25519 key agreement). bytes public_key = 3; - // Validator attestations of this key (and of `next_key` when set). + // Validator attestations of this key. // // Currently contains a single attestation from the validator that served the request. // Collecting attestations from the whole validator set requires validator intercommunication // and is planned as a follow-up; the wire format already accommodates it. repeated ValidatorKeyAttestation attestations = 4; - // Set when a key rotation is scheduled: the key that replaces the current one, and the block - // number at which it takes effect. Covered by the attestation commitment, so it cannot be - // stripped or altered without invalidating the signatures. - // - // Never set currently; key rotation is not implemented yet. - optional NextTransactionEncryptionKey next_key = 5; - // Reserved for future attestation evidence beyond the validator signatures, e.g. a TEE quote, // signature chain, compose hash, app measurement, epoch, or accepted measurement set. - reserved 6 to 9; + reserved 5 to 9; +} + +// The next transaction encryption key, announced ahead of a scheduled key rotation. +message NextTransactionEncryptionKey { + // The next encryption key, carrying its own validator attestations. The attestation commitment + // of a next key covers `rotation_block_num`, so the rotation block cannot be altered without + // invalidating the signatures. + TransactionEncryptionKey key = 1; + + // Block number at which the next key replaces the current one. + fixed32 rotation_block_num = 2; +} + +// Response for the `GetTransactionEncryptionKey` endpoint: the current transaction encryption +// key, and the next one when a key rotation is scheduled. +message TransactionEncryptionKeyResponse { + // The encryption key currently in effect. + TransactionEncryptionKey current_key = 1; + + // Set when a key rotation is scheduled: the key that replaces the current one, and the block + // number at which it takes effect. + // + // Once the chain reaches the rotation block, validators serve the announced key as the current + // one (with a fresh attestation) and this field is cleared until another rotation is scheduled. + optional NextTransactionEncryptionKey next_key = 2; } // Represents a transaction ID.