From b066e75f9d0ba694288e2ecf603a6f76eb64b285 Mon Sep 17 00:00:00 2001 From: Juan Munoz Date: Thu, 23 Jul 2026 18:57:17 -0300 Subject: [PATCH 1/2] feat: add tx encryption key rotation --- bin/validator/src/commands/mod.rs | 20 +- bin/validator/src/db/migrations.rs | 2 +- .../src/db/migrations/001_initial.sql | 8 + bin/validator/src/db/mod.rs | 135 +++++ .../src/db/sql/insert_encryption_key.sql | 2 + .../src/db/sql/load_encryption_key.sql | 1 + .../sql/max_archived_encryption_key_epoch.sql | 1 + bin/validator/src/lib.rs | 1 + bin/validator/src/server/mod.rs | 30 +- .../get_transaction_encryption_key.rs | 65 ++- .../src/server/validator_service/mod.rs | 320 +++++++++++- .../src/server/validator_service/tests.rs | 477 +++++++++++++++--- bin/validator/src/signers/mod.rs | 344 +++++++++---- .../api/get_transaction_encryption_key.rs | 6 +- crates/rpc/src/tests.rs | 42 +- .../src/network-operator/validator.md | 12 +- docs/external/src/rpc/public-api.md | 30 +- docs/internal/src/validator.md | 40 +- proto/proto/internal/validator.proto | 11 +- proto/proto/rpc.proto | 15 +- proto/proto/types/transaction.proto | 65 ++- 21 files changed, 1323 insertions(+), 304 deletions(-) create mode 100644 bin/validator/src/db/sql/insert_encryption_key.sql create mode 100644 bin/validator/src/db/sql/load_encryption_key.sql create mode 100644 bin/validator/src/db/sql/max_archived_encryption_key_epoch.sql diff --git a/bin/validator/src/commands/mod.rs b/bin/validator/src/commands/mod.rs index 15c065e167..c75884dab7 100644 --- a/bin/validator/src/commands/mod.rs +++ b/bin/validator/src/commands/mod.rs @@ -12,7 +12,6 @@ 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, @@ -132,10 +131,11 @@ pub enum ValidatorCommand { )] signing_key_kms_id: Option, - /// Hex-encoded shared secret of the transaction encryption key. + /// Hex-encoded shared master secret of the transaction encryption key. /// - /// Unlike the per-validator signing key, this value must be identical across every - /// validator in the set. + /// The per-epoch encryption keys are derived from this secret, rotating automatically at + /// each epoch boundary. 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. /// @@ -149,8 +149,8 @@ pub enum ValidatorCommand { )] encryption_key: String, - /// Base64-encoded KMS ciphertext of the shared transaction encryption key, as returned - /// by `kms:Encrypt`. + /// Base64-encoded KMS ciphertext of the shared transaction encryption master secret, 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 @@ -232,10 +232,12 @@ impl ValidatorCommand { 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 master_secret: [u8; 32] = encryption_key_bytes + .as_slice() + .try_into() + .map_err(|_| anyhow::anyhow!("the encryption key must be exactly 32 bytes"))?; let decrypter: Arc = - Arc::new(LocalX25519TransactionInputDecrypter::new(encryption_key)); + Arc::new(LocalX25519TransactionInputDecrypter::new(master_secret)); let signer = if let Some(kms_key_id) = signing_key_kms_id { ValidatorSigner::new_kms(kms_key_id).await? diff --git a/bin/validator/src/db/migrations.rs b/bin/validator/src/db/migrations.rs index 0411ce605d..0c5838a7a8 100644 --- a/bin/validator/src/db/migrations.rs +++ b/bin/validator/src/db/migrations.rs @@ -71,7 +71,7 @@ mod tests { use super::*; const EXPECTED_SCHEMA_HASHES: [SchemaHash; 1] = [SchemaHash::from_hex( - "100025e3daa05c2f7d5be2dc6ff096dbe916f1af4d95ae27cdfb2e23d6f0723a", + "c8fc914e8109e47844744acea6a590dc3364acc7cc489205143bc0ee69b54520", )]; #[test] diff --git a/bin/validator/src/db/migrations/001_initial.sql b/bin/validator/src/db/migrations/001_initial.sql index 1aa7c89388..c6e09067a9 100644 --- a/bin/validator/src/db/migrations/001_initial.sql +++ b/bin/validator/src/db/migrations/001_initial.sql @@ -17,3 +17,11 @@ CREATE TABLE block_headers ( block_num BIGINT PRIMARY KEY, block_header BLOB NOT NULL ) WITHOUT ROWID; + +CREATE TABLE encryption_keys ( + epoch BIGINT PRIMARY KEY, + scheme BIGINT NOT NULL, + key_id BLOB NOT NULL, + public_key BLOB NOT NULL, + secret_key BLOB NOT NULL +) WITHOUT ROWID; diff --git a/bin/validator/src/db/mod.rs b/bin/validator/src/db/mod.rs index b237354a55..37306e8a60 100644 --- a/bin/validator/src/db/mod.rs +++ b/bin/validator/src/db/mod.rs @@ -24,6 +24,11 @@ mod sql { pub(super) const COUNT_VALIDATED_TRANSACTIONS: &str = include_str!("sql/count_validated_transactions.sql"); pub(super) const COUNT_SIGNED_BLOCKS: &str = include_str!("sql/count_signed_blocks.sql"); + pub(super) const INSERT_ENCRYPTION_KEY: &str = include_str!("sql/insert_encryption_key.sql"); + pub(super) const MAX_ARCHIVED_ENCRYPTION_KEY_EPOCH: &str = + include_str!("sql/max_archived_encryption_key_epoch.sql"); + #[cfg(test)] + pub(super) const LOAD_ENCRYPTION_KEY: &str = include_str!("sql/load_encryption_key.sql"); } /// Open a connection to the DB after verifying that it is at the latest schema version. @@ -261,6 +266,93 @@ pub fn count_signed_blocks(tx: &ReadTx<'_>) -> Result { .unwrap_or(0)) } +/// A transaction encryption key of one epoch, as archived in the database. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ArchivedEncryptionKey { + /// Wire identifier of the encryption scheme. + pub scheme: u32, + /// Opaque identifier of the encryption key. + pub key_id: Vec, + /// Raw public key bytes of the encryption key. + pub public_key: Vec, + /// Raw secret key bytes of the encryption key. + pub secret_key: Vec, +} + +/// Archives one epoch's encryption key. +/// +/// A key is immutable once derived, so a row that already exists for the epoch is left +/// untouched. +#[miden_instrument( + target = COMPONENT, + skip(tx, key), + err, +)] +pub(crate) fn insert_encryption_key( + tx: &WriteTx<'_>, + epoch: u16, + key: &ArchivedEncryptionKey, +) -> Result<(), DatabaseError> { + tx.execute( + sql::INSERT_ENCRYPTION_KEY, + &[ + &i64::from(epoch), + &i64::from(key.scheme), + &key.key_id, + &key.public_key, + &key.secret_key, + ], + )?; + Ok(()) +} + +/// Returns the highest epoch whose encryption key has been archived, or `None` when the archive is +/// empty. +#[miden_instrument( + target = COMPONENT, + skip(tx), + err, +)] +pub(crate) fn max_archived_encryption_key_epoch( + tx: &ReadTx<'_>, +) -> Result, DatabaseError> { + tx.query(sql::MAX_ARCHIVED_ENCRYPTION_KEY_EPOCH, &[], |row| row.get::>(0))? + .into_iter() + .next() + .flatten() + .map(|epoch| { + u16::try_from(epoch).map_err(|err| { + DatabaseError::deserialization("archived epoch out of the u16 range", err) + }) + }) + .transpose() +} + +/// Loads the archived encryption key of the given epoch. +/// +/// Returns `None` if no key has been archived for the epoch. +/// +/// Test-only until an archive recovery path consumes it. +#[cfg(test)] +pub(crate) fn load_encryption_key( + tx: &ReadTx<'_>, + epoch: u16, +) -> Result, DatabaseError> { + Ok(tx + .query(sql::LOAD_ENCRYPTION_KEY, &[&i64::from(epoch)], |row| { + Ok(ArchivedEncryptionKey { + scheme: u32::try_from(row.get::(0)?).map_err(|err| { + DatabaseError::deserialization("archived scheme out of the u32 range", err) + })?, + key_id: row.get(1)?, + public_key: row.get(2)?, + secret_key: row.get(3)?, + }) + })? + .into_iter() + .next()) +} + #[cfg(test)] mod tests { use super::*; @@ -323,4 +415,47 @@ mod tests { .unwrap(); assert!(!unknown_exists, "an unknown transaction id should not be reported as existing"); } + + fn test_archived_key(marker: u8) -> ArchivedEncryptionKey { + ArchivedEncryptionKey { + scheme: 1, + key_id: vec![marker; 4], + public_key: vec![marker; 32], + secret_key: vec![marker; 32], + } + } + + /// Archived keys round-trip, the max archived epoch tracks inserts, and re-inserting an epoch + /// leaves the original row untouched. + #[tokio::test] + async fn encryption_key_archive_roundtrip() { + let temp_dir = tempfile::tempdir().expect("failed to create temp directory"); + let db = setup(temp_dir.path().join("validator.sqlite3")).await.unwrap(); + + // The archive starts empty. + let max = db.read("max_epoch", max_archived_encryption_key_epoch).await.unwrap(); + assert_eq!(max, None); + let missing = db.read("load_key", |tx| load_encryption_key(tx, 0)).await.unwrap(); + assert_eq!(missing, None); + + // Insert two epochs and read them back. + for (epoch, marker) in [(0u16, 7u8), (3u16, 9u8)] { + let key = test_archived_key(marker); + db.write("insert_key", move |tx| insert_encryption_key(tx, epoch, &key)) + .await + .unwrap(); + } + let max = db.read("max_epoch", max_archived_encryption_key_epoch).await.unwrap(); + assert_eq!(max, Some(3)); + let loaded = db.read("load_key", |tx| load_encryption_key(tx, 3)).await.unwrap(); + assert_eq!(loaded, Some(test_archived_key(9))); + + // Re-inserting an archived epoch must not overwrite the existing row. + let conflicting = test_archived_key(5); + db.write("insert_key", move |tx| insert_encryption_key(tx, 3, &conflicting)) + .await + .unwrap(); + let loaded = db.read("load_key", |tx| load_encryption_key(tx, 3)).await.unwrap(); + assert_eq!(loaded, Some(test_archived_key(9)), "archived keys must be immutable"); + } } diff --git a/bin/validator/src/db/sql/insert_encryption_key.sql b/bin/validator/src/db/sql/insert_encryption_key.sql new file mode 100644 index 0000000000..98fe6c8ab7 --- /dev/null +++ b/bin/validator/src/db/sql/insert_encryption_key.sql @@ -0,0 +1,2 @@ +INSERT OR IGNORE INTO encryption_keys (epoch, scheme, key_id, public_key, secret_key) +VALUES (?1, ?2, ?3, ?4, ?5) diff --git a/bin/validator/src/db/sql/load_encryption_key.sql b/bin/validator/src/db/sql/load_encryption_key.sql new file mode 100644 index 0000000000..ff570310ce --- /dev/null +++ b/bin/validator/src/db/sql/load_encryption_key.sql @@ -0,0 +1 @@ +SELECT scheme, key_id, public_key, secret_key FROM encryption_keys WHERE epoch = ?1 diff --git a/bin/validator/src/db/sql/max_archived_encryption_key_epoch.sql b/bin/validator/src/db/sql/max_archived_encryption_key_epoch.sql new file mode 100644 index 0000000000..d5e0dabfe7 --- /dev/null +++ b/bin/validator/src/db/sql/max_archived_encryption_key_epoch.sql @@ -0,0 +1 @@ +SELECT MAX(epoch) FROM encryption_keys diff --git a/bin/validator/src/lib.rs b/bin/validator/src/lib.rs index fce827980e..42ccaa3cda 100644 --- a/bin/validator/src/lib.rs +++ b/bin/validator/src/lib.rs @@ -7,6 +7,7 @@ mod tx_validation; pub use data_directory::DataDirectory; pub use server::ValidatorServer; pub use signers::{ + EncryptionKeySet, KmsSigner, LocalX25519TransactionInputDecrypter, NextEncryptionKeyInfo, diff --git a/bin/validator/src/server/mod.rs b/bin/validator/src/server/mod.rs index 7a2979b198..caeb32c5d3 100644 --- a/bin/validator/src/server/mod.rs +++ b/bin/validator/src/server/mod.rs @@ -94,24 +94,28 @@ impl ValidatorServer { .build_v1() .context("failed to build reflection service")?; + let service = ValidatorService::new( + self.signer, + self.decrypter, + db, + block_store, + initial_chain_tip, + initial_tx_count, + initial_block_count, + ) + .await + .context("failed to initialize validator server")?; + + // Rotate and re-attest the transaction encryption key as the chain crosses epoch + // boundaries. The task follows the committed tip and stops on shutdown. + service.spawn_key_rotation_task(shutdown.clone()); + // Build the gRPC server with the API service and trace layer. tonic::transport::Server::builder() .layer(CatchPanicLayer::custom(catch_panic_layer_fn)) .layer(TraceLayer::new_for_grpc().make_span_with(grpc_trace_fn)) .timeout(self.grpc_options.request_timeout) - .add_service(validator_api::service( - ValidatorService::new( - self.signer, - self.decrypter, - db, - block_store, - initial_chain_tip, - initial_tx_count, - initial_block_count, - ) - .await - .context("failed to initialize validator server")?, - )) + .add_service(validator_api::service(service)) .add_service(reflection_service) .serve_with_incoming_shutdown( TcpListenerStream::new(listener), 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..fc811d4c3d 100644 --- a/bin/validator/src/server/validator_service/get_transaction_encryption_key.rs +++ b/bin/validator/src/server/validator_service/get_transaction_encryption_key.rs @@ -4,17 +4,20 @@ use miden_tx::utils::serde::Serializable; use super::ValidatorService; use crate::COMPONENT; +use crate::signers::TransactionEncryptionKeyInfo; #[tonic::async_trait] impl grpc::server::validator_api::GetTransactionEncryptionKey for ValidatorService { type Input = (); - type Output = grpc::transaction::TransactionEncryptionKey; + type Output = grpc::transaction::TransactionEncryptionKeyResponse; fn decode(request: ()) -> tonic::Result { Ok(request) } - fn encode(output: Self::Output) -> tonic::Result { + fn encode( + output: Self::Output, + ) -> tonic::Result { Ok(output) } @@ -30,25 +33,47 @@ 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 + // Built entirely from in-memory attested state, so the endpoint stays available while a // backup subscription holds the serve lock. - Ok(grpc::transaction::TransactionEncryptionKey { - scheme: i32::try_from(self.encryption_key_info.scheme) - .expect("scheme identifier must fit in i32"), - key_id: self.encryption_key_info.key_id.clone(), - public_key: self.encryption_key_info.public_key.clone(), - attestations: vec![grpc::transaction::ValidatorKeyAttestation { - validator_public_key: self.signer.public_key().to_bytes(), - signature: self.encryption_key_attestation.to_bytes(), - }], - next_key: self.encryption_key_info.next_key.as_ref().map(|next| { - grpc::transaction::NextTransactionEncryptionKey { - scheme: i32::try_from(next.scheme).expect("scheme identifier must fit in i32"), - key_id: next.key_id.clone(), - public_key: next.public_key.clone(), - rotation_block_num: next.rotation_block_num, - } - }), + let attested = self.attested_encryption_keys(); + let validator_public_key = self.signer.public_key().to_bytes(); + + let current_key = encode_key( + &attested.keys.current, + &validator_public_key, + &attested.current_attestation.to_bytes(), + ); + let next_key = attested.keys.next.as_ref().map(|next| { + let attestation = attested + .next_attestation + .as_ref() + .expect("a next key is always attested together with the current key"); + grpc::transaction::NextTransactionEncryptionKey { + key: Some(encode_key(&next.key, &validator_public_key, &attestation.to_bytes())), + rotation_block_num: next.rotation_block_num, + } + }); + + Ok(grpc::transaction::TransactionEncryptionKeyResponse { + current_key: Some(current_key), + next_key, }) } } + +/// Encodes one attested encryption key in wire format. +fn encode_key( + key: &TransactionEncryptionKeyInfo, + validator_public_key: &[u8], + signature: &[u8], +) -> grpc::transaction::TransactionEncryptionKey { + grpc::transaction::TransactionEncryptionKey { + scheme: i32::try_from(key.scheme).expect("scheme identifier must fit in i32"), + key_id: key.key_id.clone(), + public_key: key.public_key.clone(), + attestations: vec![grpc::transaction::ValidatorKeyAttestation { + validator_public_key: validator_public_key.to_vec(), + signature: signature.to_vec(), + }], + } +} diff --git a/bin/validator/src/server/validator_service/mod.rs b/bin/validator/src/server/validator_service/mod.rs index d27c12692d..f39fec7b2a 100644 --- a/bin/validator/src/server/validator_service/mod.rs +++ b/bin/validator/src/server/validator_service/mod.rs @@ -1,10 +1,13 @@ use std::sync::Arc; use std::sync::atomic::AtomicU64; +use std::time::Duration; use miden_node_db::DatabaseError; use miden_node_db::sqlite::Database; use miden_node_store::BlockStore; +use miden_node_utils::shutdown::CancellationToken; use miden_node_utils::tracing::{miden_instrument, miden_span_record}; +use miden_protocol::Word; use miden_protocol::block::{ BlockHeader, BlockNumber, @@ -18,9 +21,16 @@ use miden_protocol::errors::ProposedBlockError; use miden_protocol::transaction::{TransactionHeader, TransactionId}; use tokio::sync::{Semaphore, watch}; -use crate::db::{find_unvalidated_transactions, load_block_header, load_chain_tip}; -use crate::signers::TransactionEncryptionKeyInfo; -use crate::{COMPONENT, TransactionInputDecrypter, ValidatorSigner}; +use crate::db::{ + ArchivedEncryptionKey, + find_unvalidated_transactions, + insert_encryption_key, + load_block_header, + load_chain_tip, + max_archived_encryption_key_epoch, +}; +use crate::signers::EncryptionKeySet; +use crate::{COMPONENT, LOG_TARGET, TransactionInputDecrypter, ValidatorSigner}; #[cfg(test)] mod tests; @@ -67,6 +77,23 @@ pub enum ValidatorError { NoGenesisHeader, #[error("failed to attest the transaction encryption key: {0}")] EncryptionKeyAttestationFailed(String), + #[error("failed to archive the transaction encryption key: {0}")] + EncryptionKeyArchivalFailed(String), +} + +// ATTESTED ENCRYPTION KEYS +// ================================================================================ + +/// The encryption keys of one epoch together with this validator's attestations over them. +pub(crate) struct AttestedEncryptionKeys { + /// The epoch these keys were derived for. + pub epoch: u16, + /// The current key and the key that replaces it at the next epoch boundary. + pub keys: EncryptionKeySet, + /// Signature over the current key's attestation commitment. + pub current_attestation: Signature, + /// Signature over the next key's attestation commitment, absent only when no next key exists. + pub next_attestation: Option, } // VALIDATOR SERVICE @@ -76,15 +103,14 @@ pub enum ValidatorError { /// /// Implements the gRPC API for the validator. pub(crate) struct ValidatorService { - signer: ValidatorSigner, + signer: Arc, /// Decrypter for transaction inputs sealed against the shared encryption key. - #[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 encryption keys of the epoch currently served. Replaced by the key rotation + /// task after each epoch boundary. + encryption_keys: Arc>>, + /// Commitment of the genesis block header, binding key attestations to this chain. + genesis_commitment: Word, db: Arc, block_store: BlockStore, /// Enforces mutual exclusion between backup block subscriptions and all other RPCs. Regular @@ -105,6 +131,10 @@ pub(crate) struct ValidatorService { } impl ValidatorService { + /// How long the key rotation task waits before retrying a failed rotation, in the absence of + /// newly signed blocks. + const KEY_ROTATION_RETRY_DELAY: Duration = Duration::from_secs(30); + pub(crate) async fn new( signer: ValidatorSigner, decrypter: Arc, @@ -136,28 +166,26 @@ 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. + // Derive and attest the keys of the current epoch before serving. The key rotation task + // re-derives and re-signs them after each epoch boundary, so KMS-backed signers see two + // signing calls per epoch. let genesis_commitment = db .read("load_genesis_header", |tx| load_block_header(tx, BlockNumber::GENESIS)) .await .map_err(ValidatorError::DatabaseError)? .ok_or(ValidatorError::NoGenesisHeader)? .commitment(); - let encryption_key_info = decrypter - .encryption_key() - .await - .map_err(|err| ValidatorError::EncryptionKeyAttestationFailed(err.to_string()))?; - let encryption_key_attestation = signer - .sign_commitment(encryption_key_info.attestation_commitment(genesis_commitment)) - .await - .map_err(|err| ValidatorError::EncryptionKeyAttestationFailed(err.to_string()))?; + let epoch = BlockNumber::from(initial_chain_tip).block_epoch(); + Self::archive_encryption_keys(&db, &decrypter, epoch.saturating_add(1)).await?; + let encryption_keys = + Self::attest_encryption_keys(&signer, decrypter.as_ref(), genesis_commitment, epoch) + .await?; Ok(Self { - signer, + signer: Arc::new(signer), decrypter, - encryption_key_info, - encryption_key_attestation, + encryption_keys: Arc::new(std::sync::RwLock::new(Arc::new(encryption_keys))), + genesis_commitment, serve_lock: Arc::new(tokio::sync::RwLock::new(())), db: db.into(), block_store, @@ -168,6 +196,252 @@ impl ValidatorService { }) } + /// Derives the encryption keys of the given epoch and signs their attestation commitments. + /// + /// The current key's commitment is signed with the current-key role suffix, and the next + /// key's commitment binds its rotation block. See + /// [`crate::signers::attestation_commitment`]. + async fn attest_encryption_keys( + signer: &ValidatorSigner, + decrypter: &dyn TransactionInputDecrypter, + genesis_commitment: Word, + epoch: u16, + ) -> Result { + let keys = decrypter + .encryption_keys(epoch) + .await + .map_err(|err| ValidatorError::EncryptionKeyAttestationFailed(err.to_string()))?; + let current_attestation = signer + .sign_commitment(keys.current.attestation_commitment(genesis_commitment)) + .await + .map_err(|err| ValidatorError::EncryptionKeyAttestationFailed(err.to_string()))?; + let next_attestation = match &keys.next { + Some(next) => Some( + signer + .sign_commitment(next.attestation_commitment(genesis_commitment)) + .await + .map_err(|err| { + ValidatorError::EncryptionKeyAttestationFailed(err.to_string()) + })?, + ), + None => None, + }; + + Ok(AttestedEncryptionKeys { + epoch, + keys, + current_attestation, + next_attestation, + }) + } + + /// Archives the secret encryption keys of every epoch up to and including `up_to_epoch`. + /// + /// Callers pass the epoch FOLLOWING the one being attested: the next key is announced and + /// attested a whole epoch ahead of its rotation block, so clients may already be sealing + /// against it and its secret must be archived along with the current one. + /// + /// Keys already archived are skipped, so this both backfills epochs missed while the + /// validator was offline and is a no-op when the archive is up to date. If the decrypter + /// cannot export secret key bytes (e.g. a TEE-held key), archival is skipped entirely. + async fn archive_encryption_keys( + db: &Database, + decrypter: &Arc, + up_to_epoch: u16, + ) -> Result<(), ValidatorError> { + let start = db + .read("max_archived_encryption_key_epoch", max_archived_encryption_key_epoch) + .await + .map_err(ValidatorError::DatabaseError)? + .map_or(0, |max| max.saturating_add(1)); + + for epoch in start..=up_to_epoch { + let secret_key = decrypter + .export_secret_key(epoch) + .await + .map_err(|err| ValidatorError::EncryptionKeyArchivalFailed(err.to_string()))?; + let Some(secret_key) = secret_key else { + tracing::debug!( + target: COMPONENT, + "The decrypter cannot export secret keys, skipping encryption key archival" + ); + return Ok(()); + }; + let keys = decrypter + .encryption_keys(epoch) + .await + .map_err(|err| ValidatorError::EncryptionKeyArchivalFailed(err.to_string()))?; + let key = ArchivedEncryptionKey { + scheme: keys.current.scheme, + key_id: keys.current.key_id, + public_key: keys.current.public_key, + secret_key, + }; + db.write("insert_encryption_key", move |tx| insert_encryption_key(tx, epoch, &key)) + .await + .map_err(ValidatorError::DatabaseError)?; + tracing::info!( + target: LOG_TARGET, + epoch, + "Archived the transaction encryption key" + ); + } + + Ok(()) + } + + /// Returns the attested encryption keys currently served. + pub(crate) fn attested_encryption_keys(&self) -> Arc { + self.encryption_keys + .read() + .expect("encryption key lock must not be poisoned") + .clone() + } + + /// Spawns the key rotation task, which follows the committed chain tip and re-derives and + /// re-attests the encryption keys after each epoch boundary. + /// + /// Signing happens on this task, off the request path, so a slow signer + /// never delays block signing or key requests. If attestation fails, the previous epoch's + /// state remains served and the rotation is retried on the next signed block or after + /// [`Self::KEY_ROTATION_RETRY_DELAY`], whichever comes first. + pub(crate) fn spawn_key_rotation_task( + &self, + shutdown: CancellationToken, + ) -> tokio::task::JoinHandle<()> { + let signer = Arc::clone(&self.signer); + let decrypter = Arc::clone(&self.decrypter); + let state = Arc::clone(&self.encryption_keys); + let db = Arc::clone(&self.db); + let genesis_commitment = self.genesis_commitment; + let committed_tip = self.committed_tip.subscribe(); + + tokio::spawn(async move { + loop { + let worker = tokio::spawn(Self::key_rotation_loop( + Arc::clone(&signer), + Arc::clone(&decrypter), + Arc::clone(&state), + Arc::clone(&db), + genesis_commitment, + committed_tip.clone(), + Self::KEY_ROTATION_RETRY_DELAY, + shutdown.clone(), + )); + match worker.await { + // The loop exits cleanly only on shutdown or when the tip channel closes. + Ok(()) => break, + Err(err) => { + tracing::error!( + target: LOG_TARGET, + %err, + "The key rotation task terminated abnormally, restarting it" + ); + }, + } + if shutdown.is_cancelled() { + break; + } + } + }) + } + + /// Follows the committed chain tip and re-derives, re-archives, and re-attests the encryption + /// keys after each epoch boundary. See [`Self::spawn_key_rotation_task`]. + /// + /// While a rotation is failing, retries are paced by `retry_delay` rather than by every newly + /// signed block, bounding the extra load on a possibly degraded signer. + #[expect(clippy::too_many_arguments, reason = "task inputs, spawned detached from &self")] + async fn key_rotation_loop( + signer: Arc, + decrypter: Arc, + state: Arc>>, + db: Arc, + genesis_commitment: Word, + mut committed_tip: watch::Receiver, + retry_delay: Duration, + shutdown: CancellationToken, + ) { + let mut retry_pending = false; + loop { + let retry_timer_fired = tokio::select! { + () = shutdown.cancelled() => break, + changed = committed_tip.changed() => { + if changed.is_err() { + break; + } + false + }, + () = tokio::time::sleep(retry_delay), if retry_pending => true, + }; + + let epoch = committed_tip.borrow_and_update().block_epoch(); + let served_epoch = + state.read().expect("encryption key lock must not be poisoned").epoch; + if epoch <= served_epoch { + retry_pending = false; + continue; + } + if retry_pending && !retry_timer_fired { + continue; + } + if epoch - served_epoch > 1 { + // The decrypt grace window covers a single epoch, so submissions sealed against a + // key this stale can become undecryptable. + tracing::error!( + target: LOG_TARGET, + epoch, + served_epoch, + "Serving a transaction encryption key more than one epoch stale" + ); + } + + // Archive the new epoch's secret key (and its announced next key) before attesting, so + // a failed archival is retried without spending signatures. + let archive_up_to = epoch.saturating_add(1); + if let Err(err) = Self::archive_encryption_keys(&db, &decrypter, archive_up_to).await { + tracing::warn!( + target: LOG_TARGET, + epoch, + %err, + "Failed to archive the rotated transaction encryption key, retrying shortly" + ); + retry_pending = true; + continue; + } + + match Self::attest_encryption_keys( + &signer, + decrypter.as_ref(), + genesis_commitment, + epoch, + ) + .await + { + Ok(rotated) => { + tracing::info!( + target: LOG_TARGET, + epoch, + key_id = %hex::encode(&rotated.keys.current.key_id), + "Rotated the transaction encryption key" + ); + *state.write().expect("encryption key lock must not be poisoned") = + Arc::new(rotated); + retry_pending = false; + }, + Err(err) => { + tracing::warn!( + target: LOG_TARGET, + epoch, + %err, + "Failed to attest the rotated transaction encryption key, retrying shortly" + ); + retry_pending = true; + }, + } + } + } + /// 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..718649a602 100644 --- a/bin/validator/src/server/validator_service/tests.rs +++ b/bin/validator/src/server/validator_service/tests.rs @@ -5,16 +5,15 @@ 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::block::{BlockHeader, BlockInputs, BlockNumber, 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_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::db::{load_chain_tip, load_encryption_key, setup, upsert_block_header}; +use crate::signers::attestation_commitment; use crate::{LocalX25519TransactionInputDecrypter, TransactionInputDecrypter, ValidatorSigner}; // TEST HELPERS @@ -24,11 +23,9 @@ use crate::{LocalX25519TransactionInputDecrypter, TransactionInputDecrypter, Val const TEST_ENCRYPTION_SECRET: [u8; 32] = [3u8; 32]; /// Creates a [`LocalX25519TransactionInputDecrypter`] from the shared test secret, modelling the -/// identically provisioned encryption key of a validator in the set. +/// identically provisioned encryption master secret of a validator in the set. fn test_decrypter() -> LocalX25519TransactionInputDecrypter { - let key = KeyExchangeKey::read_from_bytes(&TEST_ENCRYPTION_SECRET) - .expect("test secret should be a valid key exchange key"); - LocalX25519TransactionInputDecrypter::new(key) + LocalX25519TransactionInputDecrypter::new(TEST_ENCRYPTION_SECRET) } /// Test harness that wraps a [`Validator`] and tracks the chain MMR state needed to construct valid @@ -118,7 +115,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") @@ -735,9 +732,10 @@ async fn requests_run_concurrently() { // TRANSACTION ENCRYPTION KEY // ================================================================================================ -/// The endpoint returns the shared encryption key attested by this validator's own signing key. The -/// signature verifies over a commitment recomputed from the response fields and the chain's genesis -/// commitment, so a client needs nothing beyond the response and the chain data it already trusts. +/// The endpoint returns the current and next shared encryption keys, each attested by this +/// validator's own signing key. The signatures verify over commitments 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; @@ -745,17 +743,21 @@ 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_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 keys = test_decrypter().encryption_keys(0).await.expect("key info should be available"); + + // The current key matches the epoch-0 derivation and its attestation verifies over the + // current-key commitment. + let current = response.current_key.expect("response must carry the current key"); + let scheme = u32::try_from(current.scheme).expect("scheme must be non-negative"); + assert_eq!(scheme, keys.current.scheme); + assert_eq!(current.key_id, keys.current.key_id); + assert_eq!(current.public_key, keys.current.public_key); let commitment = - attestation_commitment(scheme, &response.key_id, genesis, &response.public_key, None); - assert_eq!(commitment, info.attestation_commitment(genesis)); + attestation_commitment(scheme, ¤t.key_id, genesis, ¤t.public_key, None); + assert_eq!(commitment, keys.current.attestation_commitment(genesis)); - let [attestation] = response.attestations.as_slice() else { + let [attestation] = current.attestations.as_slice() else { panic!("response must carry exactly the serving validator's attestation"); }; assert_eq!( @@ -769,6 +771,47 @@ async fn transaction_encryption_key_is_attested() { signature.verify(commitment, &tv.server.signer.public_key()), "attestation must verify against this validator's signing key", ); + + // The next key matches the epoch-1 derivation, rotates at the first block of epoch 1, and its + // attestation verifies over the next-key commitment binding the rotation block. + let next = response.next_key.expect("a next key must be announced"); + let expected_next = keys.next.expect("epoch 0 must have a next key"); + assert_eq!(next.rotation_block_num, expected_next.rotation_block_num); + assert_eq!(next.rotation_block_num, BlockNumber::from_epoch(1).as_u32()); + + let next_key = next.key.expect("the next key must carry its key material"); + let next_scheme = u32::try_from(next_key.scheme).expect("scheme must be non-negative"); + assert_eq!(next_key.key_id, expected_next.key.key_id); + assert_eq!(next_key.public_key, expected_next.key.public_key); + assert_ne!(next_key.public_key, current.public_key, "the next key must differ"); + + let next_commitment = attestation_commitment( + next_scheme, + &next_key.key_id, + genesis, + &next_key.public_key, + Some(next.rotation_block_num), + ); + assert_eq!(next_commitment, expected_next.attestation_commitment(genesis)); + let next_signature = Signature::read_from_bytes(&next_key.attestations[0].signature) + .expect("signature should deserialize"); + assert!( + next_signature.verify(next_commitment, &tv.server.signer.public_key()), + "the next-key attestation must verify against this validator's signing key", + ); + assert!( + !next_signature.verify( + attestation_commitment( + next_scheme, + &next_key.key_id, + genesis, + &next_key.public_key, + None + ), + &tv.server.signer.public_key(), + ), + "a next-key attestation must not verify as a current-key attestation", + ); } /// Two validators provisioned with the same shared encryption secret but distinct signing keys @@ -778,14 +821,14 @@ 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; + let key_a = tv_a.call_get_transaction_encryption_key().await.current_key.unwrap(); + let key_b = tv_b.call_get_transaction_encryption_key().await.current_key.unwrap(); - 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 +840,36 @@ 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 current = response.current_key.expect("response must carry the current key"); + let signature = Signature::read_from_bytes(¤t.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(current.scheme).expect("scheme must be non-negative"); - let mut tampered_public_key = response.public_key.clone(); + let mut tampered_public_key = current.public_key.clone(); tampered_public_key[0] ^= 0x01; - let mut tampered_key_id = response.key_id.clone(); + let mut tampered_key_id = current.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 = current.key_id.clone(); + extended_key_id.push(current.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 + 1, ¤t.key_id, genesis, ¤t.public_key, None), + attestation_commitment(scheme, &tampered_key_id, genesis, ¤t.public_key, None), + attestation_commitment(scheme, &extended_key_id, genesis, ¤t.public_key[1..], None), + attestation_commitment(scheme, ¤t.key_id, genesis, &tampered_public_key, None), attestation_commitment( scheme, - &response.key_id, + ¤t.key_id, tampered_genesis, - &response.public_key, + ¤t.public_key, None, ), - attestation_commitment( - scheme, - &response.key_id, - genesis, - &response.public_key, - Some(&injected_next_key), - ), + // Presenting a current-key attestation as a next-key attestation must also break the + // signature, regardless of the claimed rotation block. + attestation_commitment(scheme, ¤t.key_id, genesis, ¤t.public_key, Some(100)), ]; for commitment in tampered_commitments { assert!( @@ -856,9 +888,13 @@ async fn response_key_seals_for_the_validator_set() { use miden_protocol::crypto::ies::SealingKey; let tv = TestValidator::new().await; - let response = tv.call_get_transaction_encryption_key().await; + let current = tv + .call_get_transaction_encryption_key() + .await + .current_key + .expect("response must carry the current key"); - let public_key = EncryptionPublicKey::read_from_bytes(&response.public_key) + let public_key = EncryptionPublicKey::read_from_bytes(¤t.public_key) .expect("response public key should deserialize"); let sealing_key = SealingKey::X25519XChaCha20Poly1305(public_key); @@ -871,14 +907,14 @@ async fn response_key_seals_for_the_validator_set() { let sealed = sealed.to_bytes(); let opened = test_decrypter() - .decrypt_transaction_inputs(&sealed, associated_data) + .decrypt_transaction_inputs(0, &sealed, associated_data) .await .unwrap(); assert_eq!(opened.as_slice(), plaintext); assert!( test_decrypter() - .decrypt_transaction_inputs(&sealed, b"other associated data") + .decrypt_transaction_inputs(0, &sealed, b"other associated data") .await .is_err(), "decryption must fail under mismatched associated data", @@ -897,7 +933,332 @@ 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!(!response.current_key.expect("current key must be present").public_key.is_empty()); drop(stream); } + +/// Crossing an epoch boundary rotates the served key: the previous next key becomes the current key +/// and a fresh next key is announced and attested. +#[tokio::test] +async fn rotation_task_rotates_keys_at_epoch_boundary() { + use miden_node_utils::shutdown::CancellationToken; + + let tv = TestValidator::new().await; + let genesis = tv.chain_tip.commitment(); + let shutdown = CancellationToken::new(); + let task = tv.server.spawn_key_rotation_task(shutdown.clone()); + + let before = tv.call_get_transaction_encryption_key().await; + let announced_next = before.next_key.expect("a next key must be announced").key.unwrap(); + + // A tip advancing within the same epoch must not replace the attested state. The state Arc is + // only swapped on rotation, so pointer identity detects a spurious re-attestation. + let state_before = tv.server.attested_encryption_keys(); + tv.server.committed_tip.send_replace(BlockNumber::from(5u32)); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + assert!( + std::sync::Arc::ptr_eq(&state_before, &tv.server.attested_encryption_keys()), + "an intra-epoch tip must not re-attest the encryption keys", + ); + + // Crossing into epoch 1 must rotate. The task signs asynchronously, so poll briefly. + tv.server.committed_tip.send_replace(BlockNumber::from_epoch(1)); + let rotated = tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + if tv.server.attested_encryption_keys().epoch == 1 { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + }) + .await; + assert!(rotated.is_ok(), "the key must rotate after the epoch boundary"); + + let after = tv.call_get_transaction_encryption_key().await; + let current = after.current_key.expect("current key must be present"); + assert_eq!( + current.public_key, announced_next.public_key, + "the announced next key must become the current key", + ); + + // The rotated current key carries a fresh, valid current-key attestation. + let scheme = u32::try_from(current.scheme).unwrap(); + let commitment = + attestation_commitment(scheme, ¤t.key_id, genesis, ¤t.public_key, None); + let signature = Signature::read_from_bytes(¤t.attestations[0].signature).unwrap(); + assert!(signature.verify(commitment, &tv.server.signer.public_key())); + + // A fresh next key is announced for epoch 2. + let next = after.next_key.expect("a next key must be announced after rotation"); + assert_eq!(next.rotation_block_num, BlockNumber::from_epoch(2).as_u32()); + assert_ne!(next.key.unwrap().public_key, current.public_key); + + // The rotated epoch's secret key is archived. + let archived = tv + .server + .db + .read("load_encryption_key", |tx| load_encryption_key(tx, 1)) + .await + .unwrap() + .expect("the rotated epoch's key must be archived"); + assert_eq!(archived.secret_key, test_decrypter().key_for_epoch(1).to_bytes()); + assert_eq!(archived.public_key, current.public_key); + + shutdown.cancel(); + task.await.expect("the rotation task must stop on shutdown"); +} + +/// Constructing the service archives the current epoch's secret key, and the archived material +/// matches the deterministic derivation. +#[tokio::test] +async fn startup_archives_current_epoch_key() { + let tv = TestValidator::new().await; + + let archived = tv + .server + .db + .read("load_encryption_key", |tx| load_encryption_key(tx, 0)) + .await + .unwrap() + .expect("the current epoch's key must be archived at startup"); + + let decrypter = test_decrypter(); + assert_eq!(archived.secret_key, decrypter.key_for_epoch(0).to_bytes()); + let info = decrypter.key_info_for_epoch(0); + assert_eq!(archived.scheme, info.scheme); + assert_eq!(archived.key_id, info.key_id); + assert_eq!(archived.public_key, info.public_key); + + // The announced next epoch's key is archived too: clients near a boundary may already seal + // against it. + let archived_next = tv + .server + .db + .read("load_encryption_key", |tx| load_encryption_key(tx, 1)) + .await + .unwrap() + .expect("the announced next epoch's key must be archived at startup"); + assert_eq!(archived_next.secret_key, decrypter.key_for_epoch(1).to_bytes()); +} + +/// A message sealed against an epoch's public key decrypts with the secret key recovered from the +/// archive alone, proving archived material is sufficient for recovery. +#[tokio::test] +async fn archived_secret_key_decrypts_sealed_submissions() { + use miden_protocol::crypto::dsa::eddsa_25519_sha512::KeyExchangeKey; + use miden_protocol::crypto::ies::UnsealingKey; + + let tv = TestValidator::new().await; + let archived = tv + .server + .db + .read("load_encryption_key", |tx| load_encryption_key(tx, 0)) + .await + .unwrap() + .expect("epoch 0 must be archived"); + + let mut rng = rand::rng(); + let plaintext = b"transaction inputs"; + let associated_data = b"associated data"; + let sealed = test_decrypter() + .sealing_key_for_epoch(0) + .seal_bytes_with_associated_data(&mut rng, plaintext, associated_data) + .unwrap(); + + let recovered_key = KeyExchangeKey::read_from_bytes(&archived.secret_key) + .expect("archived secret key bytes must form a valid key"); + let opened = UnsealingKey::X25519XChaCha20Poly1305(recovered_key) + .unseal_bytes_with_associated_data(sealed, associated_data) + .expect("the archived secret key must decrypt submissions of its epoch"); + assert_eq!(opened.as_slice(), plaintext); +} + +/// [`TransactionInputDecrypter`] test double wrapping the shared test decrypter with a configurable +/// secret key export, modelling failing and non-exporting (e.g. TEE) decrypters. +struct ExportOverrideDecrypter { + inner: LocalX25519TransactionInputDecrypter, + /// While set, `export_secret_key` fails. Toggleable to model a recovering fault. + fail_exports: std::sync::Arc, + /// When set, `export_secret_key` returns `None`, modelling a TEE-held key. + export_unavailable: bool, +} + +impl ExportOverrideDecrypter { + fn new(fail_exports: bool, export_unavailable: bool) -> Self { + Self { + inner: test_decrypter(), + fail_exports: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(fail_exports)), + export_unavailable, + } + } +} + +#[tonic::async_trait] +impl TransactionInputDecrypter for ExportOverrideDecrypter { + async fn encryption_keys(&self, epoch: u16) -> anyhow::Result { + self.inner.encryption_keys(epoch).await + } + + async fn decrypt_transaction_inputs( + &self, + epoch: u16, + ciphertext: &[u8], + associated_data: &[u8], + ) -> anyhow::Result> { + self.inner.decrypt_transaction_inputs(epoch, ciphertext, associated_data).await + } + + async fn export_secret_key(&self, epoch: u16) -> anyhow::Result>> { + if self.fail_exports.load(std::sync::atomic::Ordering::Relaxed) { + anyhow::bail!("export unavailable"); + } + if self.export_unavailable { + return Ok(None); + } + self.inner.export_secret_key(epoch).await + } +} + +/// A decrypter whose secret key export fails must fail service construction with an archival error, +/// since a served key that was never archived would silently void the archive guarantee. +#[tokio::test] +async fn failing_secret_key_export_fails_construction() { + 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; + let decrypter = ExportOverrideDecrypter::new(true, false); + + let result = + ValidatorService::new(signer, std::sync::Arc::new(decrypter), db, block_store, 0, 0, 0) + .await; + + assert!( + matches!(result, Err(ValidatorError::EncryptionKeyArchivalFailed(_))), + "construction must surface the archival failure", + ); +} + +/// A decrypter that cannot export secrets (e.g. a TEE-held key) skips archival entirely without +/// failing construction. +#[tokio::test] +async fn non_exporting_decrypter_skips_archival() { + 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; + let decrypter = ExportOverrideDecrypter::new(false, true); + + let server = + ValidatorService::new(signer, std::sync::Arc::new(decrypter), db, block_store, 0, 0, 0) + .await + .expect("a non-exporting decrypter must not fail construction"); + + let archived = server + .db + .read("load_encryption_key", |tx| load_encryption_key(tx, 0)) + .await + .unwrap(); + assert_eq!(archived, None, "no key must be archived when the decrypter cannot export"); +} + +/// A rotation that fails at the epoch boundary keeps serving the previous epoch's keys and +/// completes once the fault clears, via the retry timer. +#[tokio::test] +async fn failed_rotation_retries_until_it_succeeds() { + use miden_node_utils::shutdown::CancellationToken; + + 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; + let decrypter = ExportOverrideDecrypter::new(false, false); + let fail_exports = std::sync::Arc::clone(&decrypter.fail_exports); + + let server = + ValidatorService::new(signer, std::sync::Arc::new(decrypter), db, block_store, 0, 0, 0) + .await + .unwrap(); + let shutdown = CancellationToken::new(); + let task = tokio::spawn(ValidatorService::key_rotation_loop( + std::sync::Arc::clone(&server.signer), + std::sync::Arc::clone(&server.decrypter), + std::sync::Arc::clone(&server.encryption_keys), + std::sync::Arc::clone(&server.db), + server.genesis_commitment, + server.committed_tip.subscribe(), + std::time::Duration::from_millis(20), + shutdown.clone(), + )); + + // Cross the boundary while archival is failing: the previous epoch's keys stay served through + // several retry cycles. + fail_exports.store(true, std::sync::atomic::Ordering::Relaxed); + server.committed_tip.send_replace(BlockNumber::from_epoch(1)); + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + assert_eq!( + server.attested_encryption_keys().epoch, + 0, + "a failing rotation must keep serving the previous epoch's keys", + ); + + // Once the fault clears, the retry timer completes the rotation without new blocks. + fail_exports.store(false, std::sync::atomic::Ordering::Relaxed); + let rotated = tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + if server.attested_encryption_keys().epoch == 1 { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + }) + .await; + assert!(rotated.is_ok(), "the rotation must complete after the fault clears"); + + shutdown.cancel(); + task.await.expect("the rotation loop must stop on shutdown"); +} + +/// A tip skipping several epochs at once (e.g. a validator catching up) rotates directly to the +/// tip's epoch. +#[tokio::test] +async fn rotation_task_catches_up_across_multiple_epochs() { + use miden_node_utils::shutdown::CancellationToken; + + let tv = TestValidator::new().await; + let shutdown = CancellationToken::new(); + let task = tv.server.spawn_key_rotation_task(shutdown.clone()); + + tv.server.committed_tip.send_replace(BlockNumber::from_epoch(3)); + let rotated = tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + if tv.server.attested_encryption_keys().epoch == 3 { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + }) + .await; + assert!(rotated.is_ok(), "the key must rotate directly to the tip's epoch"); + + let expected = test_decrypter().encryption_keys(3).await.expect("key info should be available"); + let current = tv + .call_get_transaction_encryption_key() + .await + .current_key + .expect("current key must be present"); + assert_eq!(current.public_key, expected.current.public_key); + + // Every skipped epoch is backfilled into the archive, including the announced next epoch. + for epoch in 0..=4u16 { + let archived = tv + .server + .db + .read("load_encryption_key", move |tx| load_encryption_key(tx, epoch)) + .await + .unwrap() + .unwrap_or_else(|| panic!("epoch {epoch} must be archived")); + assert_eq!(archived.secret_key, test_decrypter().key_for_epoch(epoch).to_bytes()); + } + + shutdown.cancel(); + task.await.expect("the rotation task must stop on shutdown"); +} diff --git a/bin/validator/src/signers/mod.rs b/bin/validator/src/signers/mod.rs index 6cb3a476e7..0b1f1554ec 100644 --- a/bin/validator/src/signers/mod.rs +++ b/bin/validator/src/signers/mod.rs @@ -2,11 +2,10 @@ mod kms; pub use kms::{KmsSigner, decrypt_key_material}; use miden_node_utils::spawn::spawn_blocking_in_current_span; use miden_protocol::Word; +use miden_protocol::block::BlockNumber; use miden_protocol::crypto::dsa::ecdsa_k256_keccak::{PublicKey, Signature, SigningKey}; -use miden_protocol::crypto::dsa::eddsa_25519_sha512::{ - KeyExchangeKey, - PublicKey as EncryptionPublicKey, -}; +use miden_protocol::crypto::dsa::eddsa_25519_sha512::KeyExchangeKey; +use miden_protocol::crypto::hash::blake::Blake3_256; #[cfg(test)] use miden_protocol::crypto::ies::SealingKey; use miden_protocol::crypto::ies::{IesScheme, SealedMessage, UnsealingKey}; @@ -67,6 +66,10 @@ impl ValidatorSigner { /// signatures made with the same validator key. pub const ATTESTATION_DOMAIN: &[u8] = b"MIDEN_TX_ENCRYPTION_KEY_ATTESTATION_V1"; +/// Domain tag prefixed to the per-epoch key derivation payload, separating derived encryption key +/// seeds from any other use of the shared master secret. +pub const KEY_DERIVATION_DOMAIN: &[u8] = b"MIDEN_TX_ENCRYPTION_KEY_DERIVATION_V1"; + /// Decryption counterpart to [`ValidatorSigner`] for the shared transaction encryption /// (submission) key. /// @@ -74,64 +77,97 @@ pub const ATTESTATION_DOMAIN: &[u8] = b"MIDEN_TX_ENCRYPTION_KEY_ATTESTATION_V1"; /// 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 encryption key rotates every epoch. An implementation derives the key for any epoch from +/// its shared key material, so all validators transition to the same new key at each epoch +/// boundary without coordination. +/// /// The interface deliberately does not assume that secret key bytes exist in the validator /// process: an implementation may hold a local secret (see /// [`LocalX25519TransactionInputDecrypter`]) or delegate decryption to an external system such as /// a TEE that only exposes a decrypt operation. #[tonic::async_trait] pub trait TransactionInputDecrypter: Send + Sync { - /// Returns the public metadata of the current encryption key. - async fn encryption_key(&self) -> anyhow::Result; + /// Returns the public metadata of the encryption key for the given epoch, together with the key + /// scheduled to replace it at the next epoch boundary. + async fn encryption_keys(&self, epoch: u16) -> anyhow::Result; - /// Decrypts transaction inputs sealed against the current encryption key. + /// Decrypts transaction inputs sealed against the encryption key of the given epoch. + /// + /// Implementations should fall back to the previous epoch's key when unsealing with the + /// given epoch's key fails, granting a one-epoch grace window to submissions sealed just + /// before a rotation. /// /// The ciphertext is a serialized [`SealedMessage`]. async fn decrypt_transaction_inputs( &self, + epoch: u16, ciphertext: &[u8], associated_data: &[u8], ) -> anyhow::Result>; + + /// Returns the secret key material of the given epoch's encryption key for archival, or `None` + /// when the implementation cannot export secret key bytes (e.g. a TEE-held key) and handles its + /// own archival. + async fn export_secret_key(&self, epoch: u16) -> anyhow::Result>>; } -/// Public metadata of the shared transaction encryption key, in wire format. +/// Public metadata of a 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. + /// Opaque identifier of the encryption key. pub key_id: Vec, /// Raw public key bytes of the shared encryption key. pub public_key: Vec, - /// The next encryption key when a rotation is scheduled. Not populated yet; key rotation is not - /// implemented. - pub next_key: Option, } -/// Public metadata of the next transaction encryption key, announced ahead of a scheduled rotation, -/// in wire format. +/// Public metadata of the next transaction encryption key, announced ahead of its 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, + /// The key that replaces the current one at the rotation block. + pub key: TransactionEncryptionKeyInfo, /// Block number at which the next key replaces the current one. pub rotation_block_num: u32, } +/// The encryption key of one epoch together with its scheduled replacement. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EncryptionKeySet { + /// The key in effect during the epoch. + pub current: TransactionEncryptionKeyInfo, + /// The key that takes over at the next epoch boundary. `None` only when no next epoch exists + /// (the epoch counter is saturated). + pub next: Option, +} + impl TransactionEncryptionKeyInfo { - /// Returns the commitment signed by a validator to attest the encryption key. + /// Returns the commitment signed by a validator to attest this key as the current encryption + /// key. pub fn attestation_commitment(&self, genesis_commitment: Word) -> Word { attestation_commitment( self.scheme, &self.key_id, genesis_commitment, &self.public_key, - self.next_key.as_ref(), + None, + ) + } +} + +impl NextEncryptionKeyInfo { + /// Returns the commitment signed by a validator to attest this key as the next encryption key, + /// taking effect at the rotation block. + pub fn attestation_commitment(&self, genesis_commitment: Word) -> Word { + attestation_commitment( + self.key.scheme, + &self.key.key_id, + genesis_commitment, + &self.key.public_key, + Some(self.rotation_block_num), ) } } @@ -143,47 +179,45 @@ 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 || role_suffix`, binding every field of +/// the attested key 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. /// -/// `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. +/// `role_suffix` is a single `0` byte when the key is attested as the current key, or a `1` byte +/// followed by the rotation block number (4 bytes little-endian) when the key is attested as a +/// scheduled next key. Separating the roles means a next-key attestation cannot be presented as +/// a current-key attestation (or vice versa), and the rotation block cannot be altered without +/// invalidating the signature. 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) } @@ -200,18 +234,23 @@ fn extend_with_length_prefixed(payload: &mut Vec, field: &[u8], name: &str) payload.extend_from_slice(field); } -/// [`TransactionInputDecrypter`] backed by a locally provisioned X25519 shared secret. +/// [`TransactionInputDecrypter`] backed by a locally provisioned shared master secret, from which +/// the per-epoch X25519 keys are derived. pub struct LocalX25519TransactionInputDecrypter { - secret_key: KeyExchangeKey, + master_secret: [u8; 32], } impl LocalX25519TransactionInputDecrypter { /// The IES scheme used for transaction input encryption. pub const SCHEME: IesScheme = IesScheme::X25519XChaCha20Poly1305; - /// Constructs a decrypter from a locally provisioned shared secret. - pub fn new(secret_key: KeyExchangeKey) -> Self { - Self { secret_key } + /// Constructs a decrypter from a locally provisioned shared master secret. + /// + /// The master secret is never used as an encryption key directly. The key for each epoch is + /// derived from it, so every validator provisioned with the same secret derives the same + /// per-epoch keys. + pub fn new(master_secret: [u8; 32]) -> Self { + Self { master_secret } } /// Returns the wire representation of [`Self::SCHEME`]. @@ -219,37 +258,72 @@ impl LocalX25519TransactionInputDecrypter { u32::from(u8::from(Self::SCHEME)) } - /// Returns the public key of the shared encryption key. - pub fn public_key(&self) -> EncryptionPublicKey { - self.secret_key.public_key() + /// Derives the encryption key for the given epoch. + /// + /// The key seed is `blake3(KEY_DERIVATION_DOMAIN || master_secret || epoch)`, with the epoch + /// encoded as 2 bytes little-endian. + pub fn key_for_epoch(&self, epoch: u16) -> KeyExchangeKey { + let mut payload = + Vec::with_capacity(KEY_DERIVATION_DOMAIN.len() + self.master_secret.len() + 2); + payload.extend_from_slice(KEY_DERIVATION_DOMAIN); + payload.extend_from_slice(&self.master_secret); + payload.extend_from_slice(&epoch.to_le_bytes()); + let seed = Blake3_256::hash(&payload); + KeyExchangeKey::read_from_bytes(seed.as_bytes()) + .expect("a 32-byte seed always forms a valid key exchange key") } - /// Returns the opaque identifier of the current encryption key: the first 4 bytes of the public - /// key commitment. - pub fn key_id(&self) -> Vec { - self.public_key().to_commitment().to_bytes()[..4].to_vec() + /// Returns the public metadata of the encryption key for the given epoch. + /// + /// The key id is the first 4 bytes of the public key commitment. + pub fn key_info_for_epoch(&self, epoch: u16) -> TransactionEncryptionKeyInfo { + let public_key = self.key_for_epoch(epoch).public_key(); + TransactionEncryptionKeyInfo { + scheme: Self::scheme_id(), + key_id: public_key.to_commitment().to_bytes()[..4].to_vec(), + public_key: public_key.to_bytes(), + } } - /// Returns the sealing key that clients use to encrypt messages to the validator set. + /// Returns the sealing key that clients use to encrypt messages to the validator set during the + /// given epoch. #[cfg(test)] - pub fn sealing_key(&self) -> SealingKey { - SealingKey::X25519XChaCha20Poly1305(self.public_key()) + pub fn sealing_key_for_epoch(&self, epoch: u16) -> SealingKey { + SealingKey::X25519XChaCha20Poly1305(self.key_for_epoch(epoch).public_key()) + } + + /// Attempts to unseal a message with the key of a single epoch. + fn unseal_with_epoch_key( + &self, + epoch: u16, + message: SealedMessage, + associated_data: &[u8], + ) -> anyhow::Result> { + use anyhow::Context; + + UnsealingKey::X25519XChaCha20Poly1305(self.key_for_epoch(epoch)) + .unseal_bytes_with_associated_data(message, associated_data) + .context("failed to unseal the transaction inputs") } } #[tonic::async_trait] impl TransactionInputDecrypter for LocalX25519TransactionInputDecrypter { - async fn encryption_key(&self) -> anyhow::Result { - Ok(TransactionEncryptionKeyInfo { - scheme: Self::scheme_id(), - key_id: self.key_id(), - public_key: self.public_key().to_bytes(), - next_key: None, + async fn encryption_keys(&self, epoch: u16) -> anyhow::Result { + let next = epoch.checked_add(1).map(|next_epoch| NextEncryptionKeyInfo { + key: self.key_info_for_epoch(next_epoch), + rotation_block_num: BlockNumber::from_epoch(next_epoch).as_u32(), + }); + + Ok(EncryptionKeySet { + current: self.key_info_for_epoch(epoch), + next, }) } async fn decrypt_transaction_inputs( &self, + epoch: u16, ciphertext: &[u8], associated_data: &[u8], ) -> anyhow::Result> { @@ -257,9 +331,20 @@ 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") + + match self.unseal_with_epoch_key(epoch, message.clone(), associated_data) { + Ok(plaintext) => Ok(plaintext), + Err(err) => match epoch.checked_sub(1) { + Some(previous_epoch) => { + self.unseal_with_epoch_key(previous_epoch, message, associated_data) + }, + None => Err(err), + }, + } + } + + async fn export_secret_key(&self, epoch: u16) -> anyhow::Result>> { + Ok(Some(self.key_for_epoch(epoch).to_bytes())) } } @@ -268,75 +353,152 @@ impl TransactionInputDecrypter for LocalX25519TransactionInputDecrypter { #[cfg(test)] mod tests { - use miden_protocol::utils::serde::Deserializable; use rand::rng; use super::*; fn decrypter_from(secret: &[u8; 32]) -> LocalX25519TransactionInputDecrypter { - LocalX25519TransactionInputDecrypter::new(KeyExchangeKey::read_from_bytes(secret).unwrap()) + LocalX25519TransactionInputDecrypter::new(*secret) } - /// Loading the same shared secret must yield the same key metadata and attestation commitment - /// on every validator instance. + /// Loading the same master secret must yield the same per-epoch key metadata and attestation + /// commitments on every validator instance. #[tokio::test] async fn same_secret_yields_same_public_material() { let genesis = Word::try_from([1u64, 2, 3, 4]).unwrap(); - let info_a = decrypter_from(&[7u8; 32]).encryption_key().await.unwrap(); - let info_b = decrypter_from(&[7u8; 32]).encryption_key().await.unwrap(); + let keys_a = decrypter_from(&[7u8; 32]).encryption_keys(3).await.unwrap(); + let keys_b = decrypter_from(&[7u8; 32]).encryption_keys(3).await.unwrap(); - assert_eq!(info_a, info_b); - assert_eq!(info_a.attestation_commitment(genesis), info_b.attestation_commitment(genesis)); + assert_eq!(keys_a, keys_b); + assert_eq!( + keys_a.current.attestation_commitment(genesis), + keys_b.current.attestation_commitment(genesis) + ); + assert_eq!( + keys_a.next.as_ref().unwrap().attestation_commitment(genesis), + keys_b.next.as_ref().unwrap().attestation_commitment(genesis) + ); } - /// Different secrets must yield different public keys and key ids. + /// Different master secrets must yield different public keys and key ids. #[tokio::test] async fn different_secrets_yield_different_public_material() { let genesis = Word::try_from([1u64, 2, 3, 4]).unwrap(); - let info_a = decrypter_from(&[7u8; 32]).encryption_key().await.unwrap(); - let info_b = decrypter_from(&[8u8; 32]).encryption_key().await.unwrap(); + let keys_a = decrypter_from(&[7u8; 32]).encryption_keys(3).await.unwrap(); + let keys_b = decrypter_from(&[8u8; 32]).encryption_keys(3).await.unwrap(); + + assert_eq!(keys_a.current.scheme, keys_b.current.scheme); + assert_ne!(keys_a.current.public_key, keys_b.current.public_key); + assert_ne!(keys_a.current.key_id, keys_b.current.key_id); + assert_ne!( + keys_a.current.attestation_commitment(genesis), + keys_b.current.attestation_commitment(genesis) + ); + } - 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)); + /// Different epochs must yield different keys under the same master secret, and the next key of + /// one epoch must equal the current key of the following epoch. + #[tokio::test] + async fn epochs_yield_distinct_but_consistent_keys() { + let decrypter = decrypter_from(&[7u8; 32]); + let keys_3 = decrypter.encryption_keys(3).await.unwrap(); + let keys_4 = decrypter.encryption_keys(4).await.unwrap(); + + assert_ne!(keys_3.current.public_key, keys_4.current.public_key); + assert_ne!(keys_3.current.key_id, keys_4.current.key_id); + + let next_3 = keys_3.next.unwrap(); + assert_eq!(next_3.key, keys_4.current); + assert_eq!(next_3.rotation_block_num, BlockNumber::from_epoch(4).as_u32()); } - /// 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. + /// The current-key and next-key attestation commitments over the same key material must differ, + /// and the next-key commitment must bind the rotation block. + #[test] + fn attestation_commitment_binds_role_and_rotation_block() { + let genesis = Word::try_from([1u64, 2, 3, 4]).unwrap(); + let key = decrypter_from(&[7u8; 32]).key_info_for_epoch(3); + + let as_current = key.attestation_commitment(genesis); + let as_next = NextEncryptionKeyInfo { + key: key.clone(), + rotation_block_num: BlockNumber::from_epoch(4).as_u32(), + } + .attestation_commitment(genesis); + let as_next_other_block = NextEncryptionKeyInfo { + key, + rotation_block_num: BlockNumber::from_epoch(5).as_u32(), + } + .attestation_commitment(genesis); + + assert_ne!(as_current, as_next); + assert_ne!(as_next, as_next_other_block); + } + + /// At the final epoch no next epoch exists, so no next key can be announced. + #[tokio::test] + async fn final_epoch_has_no_next_key() { + let keys = decrypter_from(&[7u8; 32]).encryption_keys(u16::MAX).await.unwrap(); + assert!(keys.next.is_none()); + } + + /// A message sealed against an epoch's sealing key must decrypt to the original plaintext, and + /// decryption must reject a mismatched associated data or a mismatched key. #[tokio::test] async fn seal_decrypt_roundtrip() { let mut rng = rng(); let decrypter = decrypter_from(&[7u8; 32]); + let epoch = 3; let plaintext = b"transaction inputs"; let associated_data = b"scheme|key_id|chain|tx"; let sealed = decrypter - .sealing_key() + .sealing_key_for_epoch(epoch) .seal_bytes_with_associated_data(&mut rng, plaintext, associated_data) .unwrap() .to_bytes(); - let opened = decrypter.decrypt_transaction_inputs(&sealed, associated_data).await.unwrap(); + let opened = decrypter + .decrypt_transaction_inputs(epoch, &sealed, associated_data) + .await + .unwrap(); assert_eq!(opened.as_slice(), plaintext); // Mismatched associated data must fail authentication. assert!( decrypter - .decrypt_transaction_inputs(&sealed, b"wrong associated data") + .decrypt_transaction_inputs(epoch, &sealed, b"wrong associated data") .await .is_err() ); - // A different shared secret must fail to decrypt. + // A different master secret must fail to decrypt. let other = decrypter_from(&[8u8; 32]); - assert!(other.decrypt_transaction_inputs(&sealed, associated_data).await.is_err()); + assert!(other.decrypt_transaction_inputs(epoch, &sealed, associated_data).await.is_err()); // Garbage ciphertext must fail to deserialize. assert!( decrypter - .decrypt_transaction_inputs(b"not a sealed message", associated_data) + .decrypt_transaction_inputs(epoch, b"not a sealed message", associated_data) .await .is_err() ); } + + /// A message sealed during epoch `e` must remain decryptable during epoch `e + 1` (grace + /// window) but not during epoch `e + 2`. + #[tokio::test] + async fn previous_epoch_key_grants_grace_window() { + let mut rng = rng(); + let decrypter = decrypter_from(&[7u8; 32]); + let associated_data = b"associated data"; + + let sealed = decrypter + .sealing_key_for_epoch(3) + .seal_bytes_with_associated_data(&mut rng, b"inputs", associated_data) + .unwrap() + .to_bytes(); + + assert!(decrypter.decrypt_transaction_inputs(4, &sealed, associated_data).await.is_ok()); + assert!(decrypter.decrypt_transaction_inputs(5, &sealed, 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 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..638420818d 100644 --- a/docs/external/src/network-operator/validator.md +++ b/docs/external/src/network-operator/validator.md @@ -38,9 +38,15 @@ miden-validator start \ For local development, the validator can use its default insecure development key. Production deployments should configure validator signing explicitly, either with a local key or with KMS-backed signing. -In addition to its signing key, every validator holds the shared transaction encryption key, configured with -`--encryption-key.hex` or `MIDEN_VALIDATOR_ENCRYPTION_KEY`. Unlike the signing key, this value must be identical across -every validator in the set. The validator logs a warning at startup if the insecure development default is in use. +In addition to its signing key, every validator holds the shared transaction encryption master secret, configured with +`--encryption-key.hex` or `MIDEN_VALIDATOR_ENCRYPTION_KEY`. The actual encryption keys are derived from this secret per +epoch and rotate automatically at each epoch boundary, with no operator action required. Unlike the signing key, this +value must be identical across every validator in the set. The validator logs a warning at startup if the insecure +development default is in use. + +Each epoch's derived secret key is archived in the validator's database, preserving the key material needed to recover +past submissions should the master secret ever be replaced (the live decrypt path always re-derives from the master +secret). The archive holds raw key material, so the validator's data directory must be protected accordingly. Production deployments should not pass the secret in plaintext. Instead, wrap it with a symmetric AWS KMS key (`aws kms encrypt`) and pass the resulting base64 ciphertext blob unchanged via `--encryption-key.kms-ciphertext` or diff --git a/docs/external/src/rpc/public-api.md b/docs/external/src/rpc/public-api.md index 5e92b9b5e3..3cfe9eb618 100644 --- a/docs/external/src/rpc/public-api.md +++ b/docs/external/src/rpc/public-api.md @@ -32,20 +32,22 @@ grpcurl rpc.testnet.miden.io:443 describe rpc.Api ## Transaction Submission -| Method | Purpose | -| ----------------------------- | ------------------------------------------------------------------------------------------- | -| `GetTransactionEncryptionKey` | Returns the transaction encryption public key, attested by a validator's signing key. | -| `SubmitProvenTx` | Submits one proven transaction and returns the node's current block height. | -| `SubmitProvenTxBatch` | Submits an atomic batch of proven transactions and returns the node's current block height. | - -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. +| Method | Purpose | +| ----------------------------- | ------------------------------------------------------------------------------------------------------- | +| `GetTransactionEncryptionKey` | Returns the current and next transaction encryption public keys, attested by a validator's signing key. | +| `SubmitProvenTx` | Submits one proven transaction and returns the node's current block height. | +| `SubmitProvenTxBatch` | Submits an atomic batch of proven transactions and returns the node's current block height. | + +The keys returned by `GetTransactionEncryptionKey` are shared across the whole validator set, while each attestation is +specific to one validator (currently each key carries a single attestation). The encryption key rotates every epoch +(`2^16` blocks): the response carries the key currently in effect and the key that replaces it at the next epoch +boundary, together with the rotation block number. 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 `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: a replayed older +signed key still verifies until a chain or epoch rule for freshness exists, though the rotation block bound into +next-key attestations lets clients detect stale keys once the chain has passed that block. 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..8d7b624202 100644 --- a/docs/internal/src/validator.md +++ b/docs/internal/src/validator.md @@ -29,17 +29,35 @@ Once verified, the block is signed and returned to the sender. ## Transaction encryption key In addition to its per-validator signing key, every validator is provisioned with the _same_ -shared transaction encryption keypair, an Ed25519 key that miden-crypto uses for X25519 key -agreement in its IES scheme. Clients will use it to encrypt the private transaction inputs they -submit, so that any validator in the set can decrypt them. - -The `GetTransactionEncryptionKey` endpoint returns the shared public key together with an IES -scheme identifier, an opaque key ID, and a list of validator attestations, currently holding one -signature from this validator's own signing key over an attestation commitment (the -`TransactionEncryptionKey` proto message documents the exact payload). The commitment carries a domain tag that separates attestations from block header -signatures, and the genesis commitment so an attestation cannot replay across networks. The -signature proves to clients that a chain-recognized validator vouches for the key, so the key can -be served through an untrusted RPC. +shared master secret for transaction encryption. From it, the validator derives one encryption +keypair per epoch (an Ed25519 key that miden-crypto uses for X25519 key agreement in its IES +scheme): the key seed is the blake3 hash of a domain tag, the master secret, and the epoch +number. Clients will use the epoch's key to encrypt the private transaction inputs they submit, +so that any validator in the set can decrypt them. + +The encryption key rotates at every epoch boundary (every `2^16` blocks). Since the derivation +is deterministic, all validators transition to the same new key without coordination. A +background task follows the validator's committed chain tip and, after each boundary, derives +the new epoch's keys and re-signs their attestations off the request path. Submissions sealed +against the previous epoch's key remain decryptable for one further epoch as a grace window. + +Each epoch's secret key is archived in the validator's database, at startup (backfilling any +epochs missed while offline) and at every rotation, always including the announced next epoch's +key since clients near a boundary may already seal against it. The archive preserves the key +material needed to recover past submissions should the shared master secret ever be replaced; +the live decrypt path always re-derives from the master secret. Decrypter implementations that +cannot export secret key bytes (e.g. a future TEE-held key) skip this archival and are +responsible for their own. + +The `GetTransactionEncryptionKey` endpoint returns the current key and the key that replaces it +at the next epoch boundary, each carrying an IES scheme identifier, an opaque key ID, and a list +of validator attestations, currently holding one signature from this validator's own signing key +over an attestation commitment (the `ValidatorKeyAttestation` proto message documents the exact +payload). The commitment carries a domain tag that separates attestations from block header +signatures, the genesis commitment so an attestation cannot replay across networks, and a role +suffix that separates current-key from next-key attestations and binds the next key's rotation +block. 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 diff --git a/proto/proto/internal/validator.proto b/proto/proto/internal/validator.proto index c7d7d09b18..e3ff2712c1 100644 --- a/proto/proto/internal/validator.proto +++ b/proto/proto/internal/validator.proto @@ -31,10 +31,13 @@ service Api { // Returns the public key that clients use to encrypt the private inputs of the transactions // they submit, attested by this validator's own signing key. // - // The encryption key is shared across the whole validator set, so the returned public key is - // identical regardless of which validator serves the request. The attestation carried in the - // response is specific to this validator. - rpc GetTransactionEncryptionKey(google.protobuf.Empty) returns (transaction.TransactionEncryptionKey) {} + // The encryption keys are derived per epoch from a secret shared across the whole validator + // set, so the returned keys are identical regardless of which validator serves the request. + // The attestations carried in the response are specific to this validator. + // + // The encryption key rotates every epoch. The response carries the key currently in effect + // and, when a next epoch exists, the key that replaces it at the next epoch boundary. + rpc GetTransactionEncryptionKey(google.protobuf.Empty) returns (transaction.TransactionEncryptionKeyResponse) {} } // BLOCK SUBSCRIPTION diff --git a/proto/proto/rpc.proto b/proto/proto/rpc.proto index 3217b28b80..9103e3cfff 100644 --- a/proto/proto/rpc.proto +++ b/proto/proto/rpc.proto @@ -46,12 +46,15 @@ service Api { // Returns the public key that clients use to encrypt the private inputs of the transactions // they submit, so that validators can decrypt and validate them. // - // Every validator in the set is provisioned with the same encryption keypair, so the returned - // public key is identical regardless of which validator attests it. The key carries a list of - // validator attestations, currently containing a single one. Since all validators vouch for - // the same key, an attestation verifiable against any chain-recognized validator signing key - // is sufficient. - rpc GetTransactionEncryptionKey(google.protobuf.Empty) returns (transaction.TransactionEncryptionKey) {} + // Every validator in the set derives the same per-epoch encryption keys from a shared + // secret, so the returned keys are identical regardless of which validator attests them. + // Each key carries a list of validator attestations, currently containing a single one. + // Since all validators vouch for the same keys, an attestation verifiable against any + // chain-recognized validator signing key is sufficient. + // + // The encryption key rotates every epoch. The response carries the key currently in effect + // and, when a next epoch exists, the key that replaces it at the next epoch boundary. + 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..474b16e447 100644 --- a/proto/proto/types/transaction.proto +++ b/proto/proto/types/transaction.proto @@ -80,33 +80,16 @@ 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 - // `MIDEN_TX_ENCRYPTION_KEY_ATTESTATION_V1`, the scheme, the rotation block number, and the - // length prefixes are encoded as 4 bytes little-endian, and the genesis block commitment ties - // the attestation to one network. - // `next_key_transcript` is empty when no rotation is scheduled, or the next key's `scheme || - // len(key_id) || key_id || len(public_key) || public_key || rotation_block_num` otherwise, - // binding any scheduled rotation to the signature. The canonical construction is - // `miden_validator::attestation_commitment`. + // public_key || role_suffix`, 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. + // `role_suffix` is a single `0` byte when the key is attested as the current key, or a `1` + // byte followed by `rotation_block_num` (4 bytes little-endian) 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 +111,39 @@ 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 key that replaces the current one at the rotation block. Its attestations sign the + // next-key role suffix (`1 || rotation_block_num`), so a scheduled rotation cannot be altered + // or forged from a current-key attestation. + TransactionEncryptionKey key = 1; + + // Block number at which the next key replaces the current one. Always the first block of the + // epoch following the current key's epoch: keys rotate every epoch (`2^16` blocks). + fixed32 rotation_block_num = 2; +} + +// Response to a transaction encryption key request: the current key and the key scheduled to +// replace it. +message TransactionEncryptionKeyResponse { + // The encryption key currently in effect. + TransactionEncryptionKey current_key = 1; + + // The key that replaces `current_key` at `rotation_block_num`. Encryption keys rotate every + // epoch, so this is populated except in the degenerate case where no next epoch exists. + optional NextTransactionEncryptionKey next_key = 2; } // Represents a transaction ID. From a55f6fac7feb89dcac86f71890ea63b412e067bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Garillot?= <4142+huitseeker@users.noreply.github.com> Date: Tue, 28 Jul 2026 09:13:18 -0400 Subject: [PATCH 2/2] feat(validator): use manual transaction encryption key rotation (#2382) --- bin/validator/src/commands/mod.rs | 347 ++++-- bin/validator/src/db/migrations.rs | 2 +- .../src/db/migrations/001_initial.sql | 8 - bin/validator/src/db/mod.rs | 135 --- .../src/db/sql/insert_encryption_key.sql | 2 - .../src/db/sql/load_encryption_key.sql | 1 - .../sql/max_archived_encryption_key_epoch.sql | 1 - bin/validator/src/lib.rs | 6 +- bin/validator/src/server/mod.rs | 4 - .../get_transaction_encryption_key.rs | 46 +- .../src/server/validator_service/mod.rs | 484 ++++---- .../src/server/validator_service/tests.rs | 771 +++++-------- bin/validator/src/signers/mod.rs | 1023 +++++++++++------ crates/proto/src/domain/mod.rs | 1 + .../src/domain/transaction_encryption.rs | 467 ++++++++ crates/rpc/src/tests.rs | 16 +- crates/store/src/state/loader.rs | 4 +- .../src/network-operator/validator.md | 42 +- docs/external/src/rpc/public-api.md | 25 +- docs/internal/src/validator.md | 53 +- proto/proto/internal/validator.proto | 9 +- proto/proto/rpc.proto | 12 +- proto/proto/types/transaction.proto | 53 +- 23 files changed, 2074 insertions(+), 1438 deletions(-) delete mode 100644 bin/validator/src/db/sql/insert_encryption_key.sql delete mode 100644 bin/validator/src/db/sql/load_encryption_key.sql delete mode 100644 bin/validator/src/db/sql/max_archived_encryption_key_epoch.sql create mode 100644 crates/proto/src/domain/transaction_encryption.rs diff --git a/bin/validator/src/commands/mod.rs b/bin/validator/src/commands/mod.rs index c75884dab7..9cce64a193 100644 --- a/bin/validator/src/commands/mod.rs +++ b/bin/validator/src/commands/mod.rs @@ -11,7 +11,9 @@ use clap::Parser; use miden_node_utils::clap::GrpcOptionsInternal; use miden_node_utils::logging::OpenTelemetry; use miden_node_utils::shutdown::CancellationToken; +use miden_protocol::block::BlockNumber; use miden_protocol::crypto::dsa::ecdsa_k256_keccak::SigningKey; +use miden_protocol::crypto::dsa::eddsa_25519_sha512::KeyExchangeKey; use miden_protocol::utils::serde::Deserializable; use miden_validator::{ DataDirectory, @@ -27,6 +29,17 @@ const ENV_SIGNING_KEY: &str = "MIDEN_VALIDATOR_SIGNING_KEY"; const ENV_SIGNING_KEY_KMS_ID: &str = "MIDEN_VALIDATOR_SIGNING_KEY_KMS_ID"; const ENV_ENCRYPTION_KEY: &str = "MIDEN_VALIDATOR_ENCRYPTION_KEY"; const ENV_ENCRYPTION_KEY_KMS_CIPHERTEXT: &str = "MIDEN_VALIDATOR_ENCRYPTION_KEY_KMS_CIPHERTEXT"; +const ENV_ENCRYPTION_KEY_ACTIVATION_BLOCK: &str = "MIDEN_VALIDATOR_ENCRYPTION_KEY_ACTIVATION_BLOCK"; +const ENV_PREVIOUS_ENCRYPTION_KEY: &str = "MIDEN_VALIDATOR_PREVIOUS_ENCRYPTION_KEY"; +const ENV_PREVIOUS_ENCRYPTION_KEY_KMS_CIPHERTEXT: &str = + "MIDEN_VALIDATOR_PREVIOUS_ENCRYPTION_KEY_KMS_CIPHERTEXT"; +const ENV_PREVIOUS_ENCRYPTION_KEY_ACTIVATION_BLOCK: &str = + "MIDEN_VALIDATOR_PREVIOUS_ENCRYPTION_KEY_ACTIVATION_BLOCK"; +const ENV_NEXT_ENCRYPTION_KEY: &str = "MIDEN_VALIDATOR_NEXT_ENCRYPTION_KEY"; +const ENV_NEXT_ENCRYPTION_KEY_KMS_CIPHERTEXT: &str = + "MIDEN_VALIDATOR_NEXT_ENCRYPTION_KEY_KMS_CIPHERTEXT"; +const ENV_NEXT_ENCRYPTION_KEY_ACTIVATION_BLOCK: &str = + "MIDEN_VALIDATOR_NEXT_ENCRYPTION_KEY_ACTIVATION_BLOCK"; const ENV_GENESIS_CONFIG_FILE: &str = "MIDEN_VALIDATOR_GENESIS_CONFIG_FILE"; const ENV_SQLITE_CONNECTION_POOL_SIZE: &str = "MIDEN_VALIDATOR_SQLITE_CONNECTION_POOL_SIZE"; @@ -37,6 +50,7 @@ pub(crate) const INSECURE_SIGNING_KEY_HEX: &str = /// A predefined, insecure shared transaction encryption key for development purposes. pub(crate) const INSECURE_ENCRYPTION_KEY_HEX: &str = "0202020202020202020202020202020202020202020202020202020202020202"; +const INSECURE_ENCRYPTION_KEY_BYTES: [u8; 32] = [2; 32]; // VALIDATOR COMMAND // ================================================================================================ @@ -131,42 +145,191 @@ pub enum ValidatorCommand { )] signing_key_kms_id: Option, - /// Hex-encoded shared master secret of the transaction encryption key. - /// - /// The per-epoch encryption keys are derived from this secret, rotating automatically at - /// each epoch boundary. Unlike the per-validator signing key, this value must be - /// identical across every validator in the set. - /// - /// If not provided, a predefined insecure key is used. - /// - /// Cannot be used with `encryption-key.kms-ciphertext`. - #[arg( - long = "encryption-key.hex", - env = ENV_ENCRYPTION_KEY, - value_name = "VALIDATOR_ENCRYPTION_KEY", - default_value = INSECURE_ENCRYPTION_KEY_HEX, - group = "encryption_key_source" - )] - encryption_key: String, - - /// Base64-encoded KMS ciphertext of the shared transaction encryption master secret, as - /// returned by `kms:Encrypt`. - /// - /// The wrapped key material is recovered at startup with `kms:Decrypt`. The ciphertext - /// must have been produced by `kms:Encrypt` under a symmetric KMS key, whose ID is - /// embedded in the ciphertext blob. - /// - /// Cannot be used with `encryption-key.hex`. - #[arg( - long = "encryption-key.kms-ciphertext", - env = ENV_ENCRYPTION_KEY_KMS_CIPHERTEXT, - value_name = "VALIDATOR_ENCRYPTION_KEY_KMS_CIPHERTEXT", - group = "encryption_key_source" - )] - encryption_key_kms_ciphertext: Option, + /// Manual transaction encryption key schedule. + #[command(flatten)] + encryption_keys: ValidatorEncryptionKeys, }, } +#[derive(clap::Args)] +pub(crate) struct ValidatorEncryptionKeys { + /// Hex-encoded shared current transaction encryption secret key. + /// + /// Unlike the per-validator signing key, this value must be identical across every validator + /// in the set. If not provided, a predefined insecure key is used. + #[arg( + long = "encryption-key.hex", + env = ENV_ENCRYPTION_KEY, + value_name = "VALIDATOR_ENCRYPTION_KEY", + default_value = INSECURE_ENCRYPTION_KEY_HEX, + group = "encryption_key_source" + )] + current_key: String, + + /// Base64-encoded KMS ciphertext of the current transaction encryption secret key. + #[arg( + long = "encryption-key.kms-ciphertext", + env = ENV_ENCRYPTION_KEY_KMS_CIPHERTEXT, + value_name = "VALIDATOR_ENCRYPTION_KEY_KMS_CIPHERTEXT", + group = "encryption_key_source" + )] + current_key_kms_ciphertext: Option, + + /// Epoch-boundary block at which the current key became active. + #[arg( + long = "encryption-key.activation-block", + env = ENV_ENCRYPTION_KEY_ACTIVATION_BLOCK, + value_name = "BLOCK_NUM", + default_value_t = 0 + )] + current_key_activation_block: u32, + + /// Hex-encoded previous transaction encryption secret key retained for grace decryption. + #[arg( + long = "encryption-key.previous.hex", + env = ENV_PREVIOUS_ENCRYPTION_KEY, + value_name = "PREVIOUS_VALIDATOR_ENCRYPTION_KEY", + group = "previous_encryption_key_source", + requires = "previous_key_activation_block" + )] + previous_key: Option, + + /// Base64-encoded KMS ciphertext of the previous transaction encryption secret key. + #[arg( + long = "encryption-key.previous.kms-ciphertext", + env = ENV_PREVIOUS_ENCRYPTION_KEY_KMS_CIPHERTEXT, + value_name = "PREVIOUS_VALIDATOR_ENCRYPTION_KEY_KMS_CIPHERTEXT", + group = "previous_encryption_key_source", + requires = "previous_key_activation_block" + )] + previous_key_kms_ciphertext: Option, + + /// Epoch-boundary block at which the previous key became active. + #[arg( + long = "encryption-key.previous.activation-block", + env = ENV_PREVIOUS_ENCRYPTION_KEY_ACTIVATION_BLOCK, + value_name = "BLOCK_NUM", + requires = "previous_encryption_key_source" + )] + previous_key_activation_block: Option, + + /// Hex-encoded next transaction encryption secret key. + #[arg( + long = "encryption-key.next.hex", + env = ENV_NEXT_ENCRYPTION_KEY, + value_name = "NEXT_VALIDATOR_ENCRYPTION_KEY", + group = "next_encryption_key_source", + requires = "next_key_activation_block" + )] + next_key: Option, + + /// Base64-encoded KMS ciphertext of the next transaction encryption secret key. + #[arg( + long = "encryption-key.next.kms-ciphertext", + env = ENV_NEXT_ENCRYPTION_KEY_KMS_CIPHERTEXT, + value_name = "NEXT_VALIDATOR_ENCRYPTION_KEY_KMS_CIPHERTEXT", + group = "next_encryption_key_source", + requires = "next_key_activation_block" + )] + next_key_kms_ciphertext: Option, + + /// Epoch-boundary block at which the next key will become active. + #[arg( + long = "encryption-key.next.activation-block", + env = ENV_NEXT_ENCRYPTION_KEY_ACTIVATION_BLOCK, + value_name = "BLOCK_NUM", + requires = "next_encryption_key_source" + )] + next_key_activation_block: Option, +} + +impl ValidatorEncryptionKeys { + async fn into_decrypter(self) -> anyhow::Result { + let current = + load_encryption_key(Some(self.current_key), self.current_key_kms_ciphertext, "current") + .await? + .expect("the current encryption key always has a default"); + let previous = + load_encryption_key(self.previous_key, self.previous_key_kms_ciphertext, "previous") + .await?; + let next = load_encryption_key(self.next_key, self.next_key_kms_ciphertext, "next").await?; + + let previous = + pair_key_with_activation(previous, self.previous_key_activation_block, "previous")?; + let next = pair_key_with_activation(next, self.next_key_activation_block, "next")?; + + LocalX25519TransactionInputDecrypter::from_schedule( + previous, + (current, BlockNumber::from(self.current_key_activation_block)), + next, + ) + } +} + +async fn load_encryption_key( + hex_key: Option, + kms_ciphertext: Option, + role: &str, +) -> anyhow::Result> { + let loaded_from_hex = kms_ciphertext.is_none() && hex_key.is_some(); + let key_bytes = if let Some(ciphertext) = kms_ciphertext { + let ciphertext = + base64::engine::general_purpose::STANDARD.decode(ciphertext).with_context(|| { + format!("failed to decode the {role} encryption key KMS ciphertext") + })?; + Some( + miden_validator::decrypt_key_material(ciphertext) + .await + .with_context(|| format!("failed to decrypt the {role} encryption key with KMS"))?, + ) + } else { + hex_key + .map(|key| { + hex::decode(key) + .with_context(|| format!("failed to decode the {role} encryption key hex")) + }) + .transpose()? + }; + + if key_bytes.as_deref() == Some(INSECURE_ENCRYPTION_KEY_BYTES.as_slice()) { + tracing::warn!( + target: LOG_TARGET, + role, + "Using the predefined, insecure transaction encryption key" + ); + } else if loaded_from_hex { + tracing::warn!( + target: LOG_TARGET, + role, + "Using a plaintext transaction encryption key; use KMS ciphertext in production" + ); + } + + key_bytes + .map(|key| { + KeyExchangeKey::read_from_bytes(&key) + .with_context(|| format!("failed to parse the {role} transaction encryption key")) + }) + .transpose() +} + +fn pair_key_with_activation( + key: Option, + activation_block: Option, + role: &str, +) -> anyhow::Result> { + match (key, activation_block) { + (Some(key), Some(activation_block)) => Ok(Some((key, BlockNumber::from(activation_block)))), + (None, None) => Ok(None), + (Some(_), None) => { + anyhow::bail!("{role} encryption key requires an activation block") + }, + (None, Some(_)) => { + anyhow::bail!("{role} encryption key activation requires a key") + }, + } +} + impl ValidatorCommand { pub async fn handle(self, shutdown: CancellationToken) -> anyhow::Result<()> { match self { @@ -202,42 +365,12 @@ impl ValidatorCommand { data_directory, signing_key_kms_id, sqlite_connection_pool_size, - encryption_key, - encryption_key_kms_ciphertext, + encryption_keys, .. } => { let address = listen; - - 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 master_secret: [u8; 32] = encryption_key_bytes - .as_slice() - .try_into() - .map_err(|_| anyhow::anyhow!("the encryption key must be exactly 32 bytes"))?; let decrypter: Arc = - Arc::new(LocalX25519TransactionInputDecrypter::new(master_secret)); + Arc::new(encryption_keys.into_decrypter().await?); let signer = if let Some(kms_key_id) = signing_key_kms_id { ValidatorSigner::new_kms(kms_key_id).await? @@ -314,8 +447,14 @@ impl ValidatorSigningKey { #[cfg(test)] mod tests { + use miden_protocol::crypto::utils::Serializable; + use super::*; + const KEY_A: &str = "0303030303030303030303030303030303030303030303030303030303030303"; + const KEY_B: &str = "0404040404040404040404040404040404040404040404040404040404040404"; + const KEY_C: &str = "0505050505050505050505050505050505050505050505050505050505050505"; + const BASE_START_ARGS: [&str; 6] = [ "miden-validator", "start", @@ -334,26 +473,24 @@ mod tests { #[test] fn encryption_key_defaults_to_insecure_hex() { let command = parse_start(&[]).expect("start without encryption options must parse"); - let ValidatorCommand::Start { - encryption_key, - encryption_key_kms_ciphertext, - .. - } = command - else { + let ValidatorCommand::Start { encryption_keys, .. } = command else { panic!("expected the start command"); }; - assert_eq!(encryption_key, INSECURE_ENCRYPTION_KEY_HEX); - assert_eq!(encryption_key_kms_ciphertext, None); + assert_eq!(encryption_keys.current_key, INSECURE_ENCRYPTION_KEY_HEX); + assert_eq!(encryption_keys.current_key_kms_ciphertext, None); + assert_eq!(encryption_keys.current_key_activation_block, 0); + assert!(encryption_keys.previous_key.is_none()); + assert!(encryption_keys.next_key.is_none()); } #[test] fn encryption_key_kms_ciphertext_parses_alone() { let command = parse_start(&["--encryption-key.kms-ciphertext", "deadbeef"]) .expect("KMS ciphertext without a hex key must parse"); - let ValidatorCommand::Start { encryption_key_kms_ciphertext, .. } = command else { + let ValidatorCommand::Start { encryption_keys, .. } = command else { panic!("expected the start command"); }; - assert_eq!(encryption_key_kms_ciphertext.as_deref(), Some("deadbeef")); + assert_eq!(encryption_keys.current_key_kms_ciphertext.as_deref(), Some("deadbeef")); } #[test] @@ -369,4 +506,64 @@ mod tests { }; assert_eq!(error.kind(), clap::error::ErrorKind::ArgumentConflict); } + + #[tokio::test] + async fn complete_manual_encryption_key_schedule_parses() { + let command = parse_start(&[ + "--encryption-key.previous.hex", + KEY_A, + "--encryption-key.previous.activation-block", + "0", + "--encryption-key.hex", + KEY_B, + "--encryption-key.activation-block", + "65536", + "--encryption-key.next.hex", + KEY_C, + "--encryption-key.next.activation-block", + "131072", + ]) + .expect("a complete previous, current, and next schedule must parse"); + let ValidatorCommand::Start { encryption_keys, .. } = command else { + panic!("expected the start command"); + }; + let provider = encryption_keys.into_decrypter().await.unwrap(); + let schedule = provider.encryption_key_schedule(BlockNumber::from_epoch(1)).await.unwrap(); + + let current = KeyExchangeKey::read_from_bytes(&[4; 32]).unwrap(); + let next = KeyExchangeKey::read_from_bytes(&[5; 32]).unwrap(); + assert_eq!(schedule.current_key.key_id, current.public_key().to_commitment().to_bytes()); + assert_eq!( + schedule.next_key.unwrap().key.key_id, + next.public_key().to_commitment().to_bytes() + ); + } + + #[test] + fn scheduled_key_requires_an_activation_block() { + let Err(error) = parse_start(&["--encryption-key.next.hex", KEY_C]) else { + panic!("a next key without an activation must be rejected"); + }; + assert_eq!(error.kind(), clap::error::ErrorKind::MissingRequiredArgument); + } + + #[tokio::test] + async fn startup_rejects_non_boundary_key_activation() { + let command = parse_start(&[ + "--encryption-key.previous.hex", + KEY_A, + "--encryption-key.previous.activation-block", + "0", + "--encryption-key.hex", + KEY_B, + "--encryption-key.activation-block", + "65537", + ]) + .unwrap(); + let ValidatorCommand::Start { encryption_keys, .. } = command else { + panic!("expected the start command"); + }; + + assert!(encryption_keys.into_decrypter().await.is_err()); + } } diff --git a/bin/validator/src/db/migrations.rs b/bin/validator/src/db/migrations.rs index 0c5838a7a8..0411ce605d 100644 --- a/bin/validator/src/db/migrations.rs +++ b/bin/validator/src/db/migrations.rs @@ -71,7 +71,7 @@ mod tests { use super::*; const EXPECTED_SCHEMA_HASHES: [SchemaHash; 1] = [SchemaHash::from_hex( - "c8fc914e8109e47844744acea6a590dc3364acc7cc489205143bc0ee69b54520", + "100025e3daa05c2f7d5be2dc6ff096dbe916f1af4d95ae27cdfb2e23d6f0723a", )]; #[test] diff --git a/bin/validator/src/db/migrations/001_initial.sql b/bin/validator/src/db/migrations/001_initial.sql index c6e09067a9..1aa7c89388 100644 --- a/bin/validator/src/db/migrations/001_initial.sql +++ b/bin/validator/src/db/migrations/001_initial.sql @@ -17,11 +17,3 @@ CREATE TABLE block_headers ( block_num BIGINT PRIMARY KEY, block_header BLOB NOT NULL ) WITHOUT ROWID; - -CREATE TABLE encryption_keys ( - epoch BIGINT PRIMARY KEY, - scheme BIGINT NOT NULL, - key_id BLOB NOT NULL, - public_key BLOB NOT NULL, - secret_key BLOB NOT NULL -) WITHOUT ROWID; diff --git a/bin/validator/src/db/mod.rs b/bin/validator/src/db/mod.rs index 37306e8a60..b237354a55 100644 --- a/bin/validator/src/db/mod.rs +++ b/bin/validator/src/db/mod.rs @@ -24,11 +24,6 @@ mod sql { pub(super) const COUNT_VALIDATED_TRANSACTIONS: &str = include_str!("sql/count_validated_transactions.sql"); pub(super) const COUNT_SIGNED_BLOCKS: &str = include_str!("sql/count_signed_blocks.sql"); - pub(super) const INSERT_ENCRYPTION_KEY: &str = include_str!("sql/insert_encryption_key.sql"); - pub(super) const MAX_ARCHIVED_ENCRYPTION_KEY_EPOCH: &str = - include_str!("sql/max_archived_encryption_key_epoch.sql"); - #[cfg(test)] - pub(super) const LOAD_ENCRYPTION_KEY: &str = include_str!("sql/load_encryption_key.sql"); } /// Open a connection to the DB after verifying that it is at the latest schema version. @@ -266,93 +261,6 @@ pub fn count_signed_blocks(tx: &ReadTx<'_>) -> Result { .unwrap_or(0)) } -/// A transaction encryption key of one epoch, as archived in the database. -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct ArchivedEncryptionKey { - /// Wire identifier of the encryption scheme. - pub scheme: u32, - /// Opaque identifier of the encryption key. - pub key_id: Vec, - /// Raw public key bytes of the encryption key. - pub public_key: Vec, - /// Raw secret key bytes of the encryption key. - pub secret_key: Vec, -} - -/// Archives one epoch's encryption key. -/// -/// A key is immutable once derived, so a row that already exists for the epoch is left -/// untouched. -#[miden_instrument( - target = COMPONENT, - skip(tx, key), - err, -)] -pub(crate) fn insert_encryption_key( - tx: &WriteTx<'_>, - epoch: u16, - key: &ArchivedEncryptionKey, -) -> Result<(), DatabaseError> { - tx.execute( - sql::INSERT_ENCRYPTION_KEY, - &[ - &i64::from(epoch), - &i64::from(key.scheme), - &key.key_id, - &key.public_key, - &key.secret_key, - ], - )?; - Ok(()) -} - -/// Returns the highest epoch whose encryption key has been archived, or `None` when the archive is -/// empty. -#[miden_instrument( - target = COMPONENT, - skip(tx), - err, -)] -pub(crate) fn max_archived_encryption_key_epoch( - tx: &ReadTx<'_>, -) -> Result, DatabaseError> { - tx.query(sql::MAX_ARCHIVED_ENCRYPTION_KEY_EPOCH, &[], |row| row.get::>(0))? - .into_iter() - .next() - .flatten() - .map(|epoch| { - u16::try_from(epoch).map_err(|err| { - DatabaseError::deserialization("archived epoch out of the u16 range", err) - }) - }) - .transpose() -} - -/// Loads the archived encryption key of the given epoch. -/// -/// Returns `None` if no key has been archived for the epoch. -/// -/// Test-only until an archive recovery path consumes it. -#[cfg(test)] -pub(crate) fn load_encryption_key( - tx: &ReadTx<'_>, - epoch: u16, -) -> Result, DatabaseError> { - Ok(tx - .query(sql::LOAD_ENCRYPTION_KEY, &[&i64::from(epoch)], |row| { - Ok(ArchivedEncryptionKey { - scheme: u32::try_from(row.get::(0)?).map_err(|err| { - DatabaseError::deserialization("archived scheme out of the u32 range", err) - })?, - key_id: row.get(1)?, - public_key: row.get(2)?, - secret_key: row.get(3)?, - }) - })? - .into_iter() - .next()) -} - #[cfg(test)] mod tests { use super::*; @@ -415,47 +323,4 @@ mod tests { .unwrap(); assert!(!unknown_exists, "an unknown transaction id should not be reported as existing"); } - - fn test_archived_key(marker: u8) -> ArchivedEncryptionKey { - ArchivedEncryptionKey { - scheme: 1, - key_id: vec![marker; 4], - public_key: vec![marker; 32], - secret_key: vec![marker; 32], - } - } - - /// Archived keys round-trip, the max archived epoch tracks inserts, and re-inserting an epoch - /// leaves the original row untouched. - #[tokio::test] - async fn encryption_key_archive_roundtrip() { - let temp_dir = tempfile::tempdir().expect("failed to create temp directory"); - let db = setup(temp_dir.path().join("validator.sqlite3")).await.unwrap(); - - // The archive starts empty. - let max = db.read("max_epoch", max_archived_encryption_key_epoch).await.unwrap(); - assert_eq!(max, None); - let missing = db.read("load_key", |tx| load_encryption_key(tx, 0)).await.unwrap(); - assert_eq!(missing, None); - - // Insert two epochs and read them back. - for (epoch, marker) in [(0u16, 7u8), (3u16, 9u8)] { - let key = test_archived_key(marker); - db.write("insert_key", move |tx| insert_encryption_key(tx, epoch, &key)) - .await - .unwrap(); - } - let max = db.read("max_epoch", max_archived_encryption_key_epoch).await.unwrap(); - assert_eq!(max, Some(3)); - let loaded = db.read("load_key", |tx| load_encryption_key(tx, 3)).await.unwrap(); - assert_eq!(loaded, Some(test_archived_key(9))); - - // Re-inserting an archived epoch must not overwrite the existing row. - let conflicting = test_archived_key(5); - db.write("insert_key", move |tx| insert_encryption_key(tx, 3, &conflicting)) - .await - .unwrap(); - let loaded = db.read("load_key", |tx| load_encryption_key(tx, 3)).await.unwrap(); - assert_eq!(loaded, Some(test_archived_key(9)), "archived keys must be immutable"); - } } diff --git a/bin/validator/src/db/sql/insert_encryption_key.sql b/bin/validator/src/db/sql/insert_encryption_key.sql deleted file mode 100644 index 98fe6c8ab7..0000000000 --- a/bin/validator/src/db/sql/insert_encryption_key.sql +++ /dev/null @@ -1,2 +0,0 @@ -INSERT OR IGNORE INTO encryption_keys (epoch, scheme, key_id, public_key, secret_key) -VALUES (?1, ?2, ?3, ?4, ?5) diff --git a/bin/validator/src/db/sql/load_encryption_key.sql b/bin/validator/src/db/sql/load_encryption_key.sql deleted file mode 100644 index ff570310ce..0000000000 --- a/bin/validator/src/db/sql/load_encryption_key.sql +++ /dev/null @@ -1 +0,0 @@ -SELECT scheme, key_id, public_key, secret_key FROM encryption_keys WHERE epoch = ?1 diff --git a/bin/validator/src/db/sql/max_archived_encryption_key_epoch.sql b/bin/validator/src/db/sql/max_archived_encryption_key_epoch.sql deleted file mode 100644 index d5e0dabfe7..0000000000 --- a/bin/validator/src/db/sql/max_archived_encryption_key_epoch.sql +++ /dev/null @@ -1 +0,0 @@ -SELECT MAX(epoch) FROM encryption_keys diff --git a/bin/validator/src/lib.rs b/bin/validator/src/lib.rs index 42ccaa3cda..c6037dbdad 100644 --- a/bin/validator/src/lib.rs +++ b/bin/validator/src/lib.rs @@ -7,14 +7,14 @@ mod tx_validation; pub use data_directory::DataDirectory; pub use server::ValidatorServer; pub use signers::{ - EncryptionKeySet, KmsSigner, LocalX25519TransactionInputDecrypter, - NextEncryptionKeyInfo, + NextTransactionEncryptionKey, TransactionEncryptionKeyInfo, + TransactionEncryptionKeySchedule, TransactionInputDecrypter, + TransactionInputDecryptionError, ValidatorSigner, - attestation_commitment, decrypt_key_material, }; diff --git a/bin/validator/src/server/mod.rs b/bin/validator/src/server/mod.rs index caeb32c5d3..efeed18401 100644 --- a/bin/validator/src/server/mod.rs +++ b/bin/validator/src/server/mod.rs @@ -106,10 +106,6 @@ impl ValidatorServer { .await .context("failed to initialize validator server")?; - // Rotate and re-attest the transaction encryption key as the chain crosses epoch - // boundaries. The task follows the committed tip and stops on shutdown. - service.spawn_key_rotation_task(shutdown.clone()); - // Build the gRPC server with the API service and trace layer. tonic::transport::Server::builder() .layer(CatchPanicLayer::custom(catch_panic_layer_fn)) 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 fc811d4c3d..f8322690cf 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,47 +33,43 @@ impl grpc::server::validator_api::GetTransactionEncryptionKey for ValidatorServi _metadata: &tonic::metadata::MetadataMap, _extensions: &tonic::codegen::http::Extensions, ) -> tonic::Result { - // Built entirely from in-memory attested state, so the endpoint stays available while a - // backup subscription holds the serve lock. - let attested = self.attested_encryption_keys(); + // The schedule is cached in memory and re-attested lazily at most once per epoch, so this + // endpoint remains independent of the backup serve lock. + let attested = self + .attested_encryption_key_schedule() + .await + .map_err(|err| tonic::Status::failed_precondition(err.to_string()))?; let validator_public_key = self.signer.public_key().to_bytes(); - let current_key = encode_key( - &attested.keys.current, - &validator_public_key, - &attested.current_attestation.to_bytes(), - ); - let next_key = attested.keys.next.as_ref().map(|next| { - let attestation = attested - .next_attestation - .as_ref() - .expect("a next key is always attested together with the current key"); + let current_key = encode_key(&attested.schedule.current_key); + let next_key = attested.schedule.next_key.as_ref().map(|next| { grpc::transaction::NextTransactionEncryptionKey { - key: Some(encode_key(&next.key, &validator_public_key, &attestation.to_bytes())), - rotation_block_num: next.rotation_block_num, + key: Some(encode_key(&next.key)), + activation_block_num: next.activation_block_num.as_u32(), } }); Ok(grpc::transaction::TransactionEncryptionKeyResponse { current_key: Some(current_key), next_key, + current_key_activation_block_num: attested + .schedule + .current_key_activation_block_num + .as_u32(), + attestation_epoch: u32::from(attested.epoch), + attestations: vec![grpc::transaction::ValidatorKeyAttestation { + validator_public_key, + signature: attested.attestation.to_bytes(), + }], }) } } -/// Encodes one attested encryption key in wire format. -fn encode_key( - key: &TransactionEncryptionKeyInfo, - validator_public_key: &[u8], - signature: &[u8], -) -> grpc::transaction::TransactionEncryptionKey { +/// Encodes one encryption key in wire format. +fn encode_key(key: &TransactionEncryptionKeyInfo) -> grpc::transaction::TransactionEncryptionKey { grpc::transaction::TransactionEncryptionKey { scheme: i32::try_from(key.scheme).expect("scheme identifier must fit in i32"), key_id: key.key_id.clone(), public_key: key.public_key.clone(), - attestations: vec![grpc::transaction::ValidatorKeyAttestation { - validator_public_key: validator_public_key.to_vec(), - signature: signature.to_vec(), - }], } } diff --git a/bin/validator/src/server/validator_service/mod.rs b/bin/validator/src/server/validator_service/mod.rs index f39fec7b2a..c6ff8bf19b 100644 --- a/bin/validator/src/server/validator_service/mod.rs +++ b/bin/validator/src/server/validator_service/mod.rs @@ -5,7 +5,6 @@ use std::time::Duration; use miden_node_db::DatabaseError; use miden_node_db::sqlite::Database; use miden_node_store::BlockStore; -use miden_node_utils::shutdown::CancellationToken; use miden_node_utils::tracing::{miden_instrument, miden_span_record}; use miden_protocol::Word; use miden_protocol::block::{ @@ -20,16 +19,10 @@ use miden_protocol::crypto::utils::Serializable; use miden_protocol::errors::ProposedBlockError; use miden_protocol::transaction::{TransactionHeader, TransactionId}; use tokio::sync::{Semaphore, watch}; +use tokio::time::{Instant, timeout}; -use crate::db::{ - ArchivedEncryptionKey, - find_unvalidated_transactions, - insert_encryption_key, - load_block_header, - load_chain_tip, - max_archived_encryption_key_epoch, -}; -use crate::signers::EncryptionKeySet; +use crate::db::{find_unvalidated_transactions, load_block_header, load_chain_tip}; +use crate::signers::TransactionEncryptionKeySchedule; use crate::{COMPONENT, LOG_TARGET, TransactionInputDecrypter, ValidatorSigner}; #[cfg(test)] @@ -77,28 +70,107 @@ pub enum ValidatorError { NoGenesisHeader, #[error("failed to attest the transaction encryption key: {0}")] EncryptionKeyAttestationFailed(String), - #[error("failed to archive the transaction encryption key: {0}")] - EncryptionKeyArchivalFailed(String), + #[error("timed out while {operation} the transaction encryption key schedule")] + EncryptionKeyScheduleRefreshTimedOut { operation: &'static str }, + #[error("transaction encryption key schedule refresh for epoch {epoch} is in backoff")] + EncryptionKeyScheduleRefreshBackoff { epoch: u16 }, + #[error("invalid transaction encryption key schedule: {0}")] + InvalidEncryptionKeySchedule(String), } -// ATTESTED ENCRYPTION KEYS +// ATTESTED ENCRYPTION KEY SCHEDULE // ================================================================================ -/// The encryption keys of one epoch together with this validator's attestations over them. -pub(crate) struct AttestedEncryptionKeys { - /// The epoch these keys were derived for. +/// A provider schedule together with this validator's epoch-scoped attestation. +pub(crate) struct AttestedEncryptionKeySchedule { + /// The epoch in which this schedule was attested. pub epoch: u16, - /// The current key and the key that replaces it at the next epoch boundary. - pub keys: EncryptionKeySet, - /// Signature over the current key's attestation commitment. - pub current_attestation: Signature, - /// Signature over the next key's attestation commitment, absent only when no next key exists. - pub next_attestation: Option, + /// The complete current and optional-next schedule. + pub schedule: TransactionEncryptionKeySchedule, + /// Signature over the complete schedule and its attestation epoch. + pub attestation: Signature, +} + +struct EncryptionKeyScheduleCache { + attested: Arc, + failed_refresh: Option, + refresh: Option, +} + +struct FailedEncryptionKeyScheduleRefresh { + epoch: u16, + retry_at: Instant, +} + +struct InFlightEncryptionKeyScheduleRefresh { + epoch: u16, + state: watch::Receiver, +} + +#[derive(Clone)] +enum EncryptionKeyScheduleRefreshState { + Pending, + Complete(Result, EncryptionKeyScheduleRefreshFailure>), +} + +#[derive(Clone)] +enum EncryptionKeyScheduleRefreshFailure { + Attestation(String), + Timeout { operation: &'static str }, + InvalidSchedule(String), +} + +impl EncryptionKeyScheduleRefreshFailure { + fn from_error(error: ValidatorError) -> Self { + match error { + ValidatorError::EncryptionKeyAttestationFailed(message) => Self::Attestation(message), + ValidatorError::EncryptionKeyScheduleRefreshTimedOut { operation } => { + Self::Timeout { operation } + }, + ValidatorError::InvalidEncryptionKeySchedule(message) => Self::InvalidSchedule(message), + error => Self::Attestation(error.to_string()), + } + } + + fn into_error(self) -> ValidatorError { + match self { + Self::Attestation(message) => ValidatorError::EncryptionKeyAttestationFailed(message), + Self::Timeout { operation } => { + ValidatorError::EncryptionKeyScheduleRefreshTimedOut { operation } + }, + Self::InvalidSchedule(message) => ValidatorError::InvalidEncryptionKeySchedule(message), + } + } +} + +async fn wait_for_encryption_key_schedule_refresh( + mut state: watch::Receiver, +) -> Result, ValidatorError> { + if matches!(*state.borrow(), EncryptionKeyScheduleRefreshState::Pending) { + state.changed().await.map_err(|_| { + ValidatorError::EncryptionKeyAttestationFailed( + "transaction encryption key schedule refresh stopped".to_owned(), + ) + })?; + } + + match state.borrow().clone() { + EncryptionKeyScheduleRefreshState::Pending => { + Err(ValidatorError::EncryptionKeyAttestationFailed( + "transaction encryption key schedule refresh did not complete".to_owned(), + )) + }, + EncryptionKeyScheduleRefreshState::Complete(Ok(attested)) => Ok(attested), + EncryptionKeyScheduleRefreshState::Complete(Err(error)) => Err(error.into_error()), + } } // VALIDATOR SERVICE // ================================================================================ +const ENCRYPTION_KEY_REFRESH_RETRY_DELAY: Duration = Duration::from_secs(30); +const ENCRYPTION_KEY_REFRESH_TIMEOUT: Duration = Duration::from_secs(10); + /// The underlying implementation of the gRPC validator server. /// /// Implements the gRPC API for the validator. @@ -106,9 +178,10 @@ pub(crate) struct ValidatorService { signer: Arc, /// Decrypter for transaction inputs sealed against the shared encryption key. decrypter: Arc, - /// The attested encryption keys of the epoch currently served. Replaced by the key rotation - /// task after each epoch boundary. - encryption_keys: Arc>>, + /// Attested provider schedule and request-path refresh backoff. + encryption_key_schedule: Arc>, + /// Bounds provider and signing calls made while refreshing the schedule. + encryption_key_refresh_timeout: Duration, /// Commitment of the genesis block header, binding key attestations to this chain. genesis_commitment: Word, db: Arc, @@ -131,10 +204,6 @@ pub(crate) struct ValidatorService { } impl ValidatorService { - /// How long the key rotation task waits before retrying a failed rotation, in the absence of - /// newly signed blocks. - const KEY_ROTATION_RETRY_DELAY: Duration = Duration::from_secs(30); - pub(crate) async fn new( signer: ValidatorSigner, decrypter: Arc, @@ -166,25 +235,35 @@ impl ValidatorService { }); } - // Derive and attest the keys of the current epoch before serving. The key rotation task - // re-derives and re-signs them after each epoch boundary, so KMS-backed signers see two - // signing calls per epoch. + // Attest the provider-owned schedule before serving. The same schedule is re-attested + // lazily once per epoch for freshness, without deriving or scheduling any rotation. let genesis_commitment = db .read("load_genesis_header", |tx| load_block_header(tx, BlockNumber::GENESIS)) .await .map_err(ValidatorError::DatabaseError)? .ok_or(ValidatorError::NoGenesisHeader)? .commitment(); - let epoch = BlockNumber::from(initial_chain_tip).block_epoch(); - Self::archive_encryption_keys(&db, &decrypter, epoch.saturating_add(1)).await?; - let encryption_keys = - Self::attest_encryption_keys(&signer, decrypter.as_ref(), genesis_commitment, epoch) - .await?; + let chain_tip = BlockNumber::from(initial_chain_tip); + let encryption_key_schedule = Self::attest_encryption_key_schedule( + &signer, + decrypter.as_ref(), + genesis_commitment, + chain_tip, + ENCRYPTION_KEY_REFRESH_TIMEOUT, + ) + .await?; Ok(Self { signer: Arc::new(signer), decrypter, - encryption_keys: Arc::new(std::sync::RwLock::new(Arc::new(encryption_keys))), + encryption_key_schedule: Arc::new(tokio::sync::Mutex::new( + EncryptionKeyScheduleCache { + attested: Arc::new(encryption_key_schedule), + failed_refresh: None, + refresh: None, + }, + )), + encryption_key_refresh_timeout: ENCRYPTION_KEY_REFRESH_TIMEOUT, genesis_commitment, serve_lock: Arc::new(tokio::sync::RwLock::new(())), db: db.into(), @@ -196,249 +275,120 @@ impl ValidatorService { }) } - /// Derives the encryption keys of the given epoch and signs their attestation commitments. - /// - /// The current key's commitment is signed with the current-key role suffix, and the next - /// key's commitment binds its rotation block. See - /// [`crate::signers::attestation_commitment`]. - async fn attest_encryption_keys( + /// Fetches, validates, and signs the provider schedule effective at `chain_tip`. + async fn attest_encryption_key_schedule( signer: &ValidatorSigner, decrypter: &dyn TransactionInputDecrypter, genesis_commitment: Word, - epoch: u16, - ) -> Result { - let keys = decrypter - .encryption_keys(epoch) - .await - .map_err(|err| ValidatorError::EncryptionKeyAttestationFailed(err.to_string()))?; - let current_attestation = signer - .sign_commitment(keys.current.attestation_commitment(genesis_commitment)) + chain_tip: BlockNumber, + refresh_timeout: Duration, + ) -> Result { + let schedule = timeout(refresh_timeout, decrypter.encryption_key_schedule(chain_tip)) .await + .map_err(|_| ValidatorError::EncryptionKeyScheduleRefreshTimedOut { + operation: "loading", + })? .map_err(|err| ValidatorError::EncryptionKeyAttestationFailed(err.to_string()))?; - let next_attestation = match &keys.next { - Some(next) => Some( - signer - .sign_commitment(next.attestation_commitment(genesis_commitment)) - .await - .map_err(|err| { - ValidatorError::EncryptionKeyAttestationFailed(err.to_string()) - })?, - ), - None => None, - }; - - Ok(AttestedEncryptionKeys { - epoch, - keys, - current_attestation, - next_attestation, - }) - } - - /// Archives the secret encryption keys of every epoch up to and including `up_to_epoch`. - /// - /// Callers pass the epoch FOLLOWING the one being attested: the next key is announced and - /// attested a whole epoch ahead of its rotation block, so clients may already be sealing - /// against it and its secret must be archived along with the current one. - /// - /// Keys already archived are skipped, so this both backfills epochs missed while the - /// validator was offline and is a no-op when the archive is up to date. If the decrypter - /// cannot export secret key bytes (e.g. a TEE-held key), archival is skipped entirely. - async fn archive_encryption_keys( - db: &Database, - decrypter: &Arc, - up_to_epoch: u16, - ) -> Result<(), ValidatorError> { - let start = db - .read("max_archived_encryption_key_epoch", max_archived_encryption_key_epoch) - .await - .map_err(ValidatorError::DatabaseError)? - .map_or(0, |max| max.saturating_add(1)); - - for epoch in start..=up_to_epoch { - let secret_key = decrypter - .export_secret_key(epoch) - .await - .map_err(|err| ValidatorError::EncryptionKeyArchivalFailed(err.to_string()))?; - let Some(secret_key) = secret_key else { - tracing::debug!( - target: COMPONENT, - "The decrypter cannot export secret keys, skipping encryption key archival" - ); - return Ok(()); - }; - let keys = decrypter - .encryption_keys(epoch) - .await - .map_err(|err| ValidatorError::EncryptionKeyArchivalFailed(err.to_string()))?; - let key = ArchivedEncryptionKey { - scheme: keys.current.scheme, - key_id: keys.current.key_id, - public_key: keys.current.public_key, - secret_key, - }; - db.write("insert_encryption_key", move |tx| insert_encryption_key(tx, epoch, &key)) - .await - .map_err(ValidatorError::DatabaseError)?; - tracing::info!( - target: LOG_TARGET, - epoch, - "Archived the transaction encryption key" - ); - } - - Ok(()) - } - - /// Returns the attested encryption keys currently served. - pub(crate) fn attested_encryption_keys(&self) -> Arc { - self.encryption_keys - .read() - .expect("encryption key lock must not be poisoned") - .clone() + schedule + .validate_at(chain_tip) + .map_err(|err| ValidatorError::InvalidEncryptionKeySchedule(err.to_string()))?; + let epoch = chain_tip.block_epoch(); + let attestation = timeout( + refresh_timeout, + signer.sign_commitment(schedule.attestation_commitment(genesis_commitment, epoch)), + ) + .await + .map_err(|_| ValidatorError::EncryptionKeyScheduleRefreshTimedOut { operation: "signing" })? + .map_err(|err| ValidatorError::EncryptionKeyAttestationFailed(err.to_string()))?; + + Ok(AttestedEncryptionKeySchedule { epoch, schedule, attestation }) } - /// Spawns the key rotation task, which follows the committed chain tip and re-derives and - /// re-attests the encryption keys after each epoch boundary. - /// - /// Signing happens on this task, off the request path, so a slow signer - /// never delays block signing or key requests. If attestation fails, the previous epoch's - /// state remains served and the rotation is retried on the next signed block or after - /// [`Self::KEY_ROTATION_RETRY_DELAY`], whichever comes first. - pub(crate) fn spawn_key_rotation_task( + /// Returns an epoch-fresh attestation without changing provider rotation policy. + pub(crate) async fn attested_encryption_key_schedule( &self, - shutdown: CancellationToken, - ) -> tokio::task::JoinHandle<()> { - let signer = Arc::clone(&self.signer); - let decrypter = Arc::clone(&self.decrypter); - let state = Arc::clone(&self.encryption_keys); - let db = Arc::clone(&self.db); - let genesis_commitment = self.genesis_commitment; - let committed_tip = self.committed_tip.subscribe(); - - tokio::spawn(async move { - loop { - let worker = tokio::spawn(Self::key_rotation_loop( - Arc::clone(&signer), - Arc::clone(&decrypter), - Arc::clone(&state), - Arc::clone(&db), - genesis_commitment, - committed_tip.clone(), - Self::KEY_ROTATION_RETRY_DELAY, - shutdown.clone(), - )); - match worker.await { - // The loop exits cleanly only on shutdown or when the tip channel closes. - Ok(()) => break, - Err(err) => { - tracing::error!( - target: LOG_TARGET, - %err, - "The key rotation task terminated abnormally, restarting it" - ); - }, - } - if shutdown.is_cancelled() { - break; - } - } - }) - } - - /// Follows the committed chain tip and re-derives, re-archives, and re-attests the encryption - /// keys after each epoch boundary. See [`Self::spawn_key_rotation_task`]. - /// - /// While a rotation is failing, retries are paced by `retry_delay` rather than by every newly - /// signed block, bounding the extra load on a possibly degraded signer. - #[expect(clippy::too_many_arguments, reason = "task inputs, spawned detached from &self")] - async fn key_rotation_loop( - signer: Arc, - decrypter: Arc, - state: Arc>>, - db: Arc, - genesis_commitment: Word, - mut committed_tip: watch::Receiver, - retry_delay: Duration, - shutdown: CancellationToken, - ) { - let mut retry_pending = false; + ) -> Result, ValidatorError> { loop { - let retry_timer_fired = tokio::select! { - () = shutdown.cancelled() => break, - changed = committed_tip.changed() => { - if changed.is_err() { - break; - } - false - }, - () = tokio::time::sleep(retry_delay), if retry_pending => true, - }; - - let epoch = committed_tip.borrow_and_update().block_epoch(); - let served_epoch = - state.read().expect("encryption key lock must not be poisoned").epoch; - if epoch <= served_epoch { - retry_pending = false; - continue; - } - if retry_pending && !retry_timer_fired { - continue; + let mut cached = self.encryption_key_schedule.lock().await; + let chain_tip = *self.committed_tip.borrow(); + let epoch = chain_tip.block_epoch(); + if cached.attested.epoch == epoch { + return Ok(Arc::clone(&cached.attested)); } - if epoch - served_epoch > 1 { - // The decrypt grace window covers a single epoch, so submissions sealed against a - // key this stale can become undecryptable. - tracing::error!( - target: LOG_TARGET, - epoch, - served_epoch, - "Serving a transaction encryption key more than one epoch stale" - ); + if cached + .failed_refresh + .as_ref() + .is_some_and(|failure| failure.epoch == epoch && Instant::now() < failure.retry_at) + { + return Err(ValidatorError::EncryptionKeyScheduleRefreshBackoff { epoch }); } - - // Archive the new epoch's secret key (and its announced next key) before attesting, so - // a failed archival is retried without spending signatures. - let archive_up_to = epoch.saturating_add(1); - if let Err(err) = Self::archive_encryption_keys(&db, &decrypter, archive_up_to).await { - tracing::warn!( - target: LOG_TARGET, - epoch, - %err, - "Failed to archive the rotated transaction encryption key, retrying shortly" - ); - retry_pending = true; + if let Some(refresh) = &cached.refresh { + let refresh_epoch = refresh.epoch; + let state = refresh.state.clone(); + drop(cached); + let result = wait_for_encryption_key_schedule_refresh(state).await; + if refresh_epoch == epoch { + return result; + } continue; } - match Self::attest_encryption_keys( - &signer, - decrypter.as_ref(), - genesis_commitment, - epoch, - ) - .await - { - Ok(rotated) => { - tracing::info!( - target: LOG_TARGET, - epoch, - key_id = %hex::encode(&rotated.keys.current.key_id), - "Rotated the transaction encryption key" - ); - *state.write().expect("encryption key lock must not be poisoned") = - Arc::new(rotated); - retry_pending = false; - }, - Err(err) => { - tracing::warn!( - target: LOG_TARGET, - epoch, - %err, - "Failed to attest the rotated transaction encryption key, retrying shortly" - ); - retry_pending = true; - }, - } + let (state_tx, state_rx) = watch::channel(EncryptionKeyScheduleRefreshState::Pending); + cached.refresh = + Some(InFlightEncryptionKeyScheduleRefresh { epoch, state: state_rx.clone() }); + drop(cached); + + let signer = Arc::clone(&self.signer); + let decrypter = Arc::clone(&self.decrypter); + let encryption_key_schedule = Arc::clone(&self.encryption_key_schedule); + let genesis_commitment = self.genesis_commitment; + let refresh_timeout = self.encryption_key_refresh_timeout; + let refresh = tokio::spawn(async move { + Self::attest_encryption_key_schedule( + &signer, + decrypter.as_ref(), + genesis_commitment, + chain_tip, + refresh_timeout, + ) + .await + }); + tokio::spawn(async move { + let result = match refresh.await { + Ok(result) => result, + Err(err) => Err(ValidatorError::EncryptionKeyAttestationFailed(format!( + "transaction encryption key schedule refresh task failed: {err}" + ))), + }; + let mut cached = encryption_key_schedule.lock().await; + let state = match result { + Ok(attested) => { + cached.failed_refresh = None; + let attested = Arc::new(attested); + tracing::info!( + target: LOG_TARGET, + epoch = attested.epoch, + key_id = %hex::encode(&attested.schedule.current_key.key_id), + "Refreshed the transaction encryption key schedule attestation" + ); + cached.attested = Arc::clone(&attested); + EncryptionKeyScheduleRefreshState::Complete(Ok(attested)) + }, + Err(err) => { + cached.failed_refresh = Some(FailedEncryptionKeyScheduleRefresh { + epoch, + retry_at: Instant::now() + ENCRYPTION_KEY_REFRESH_RETRY_DELAY, + }); + EncryptionKeyScheduleRefreshState::Complete(Err( + EncryptionKeyScheduleRefreshFailure::from_error(err), + )) + }, + }; + cached.refresh = None; + state_tx.send_replace(state); + }); + + return wait_for_encryption_key_schedule_refresh(state_rx).await; } } diff --git a/bin/validator/src/server/validator_service/tests.rs b/bin/validator/src/server/validator_service/tests.rs index 718649a602..d4afbdf6b1 100644 --- a/bin/validator/src/server/validator_service/tests.rs +++ b/bin/validator/src/server/validator_service/tests.rs @@ -1,19 +1,26 @@ use std::collections::BTreeMap; - +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::time::Duration; + +use miden_node_proto::domain::transaction_encryption::{ + TrustedChainState, + verify_transaction_encryption_key_schedule, +}; 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, BlockNumber, ProposedBlock}; -use miden_protocol::crypto::dsa::ecdsa_k256_keccak::{Signature, SigningKey}; +use miden_protocol::crypto::dsa::ecdsa_k256_keccak::SigningKey; +use miden_protocol::crypto::dsa::eddsa_25519_sha512::KeyExchangeKey; use miden_protocol::testing::random_secret_key::random_secret_key; use miden_protocol::transaction::PartialBlockchain; use miden_tx::utils::serde::{Deserializable, Serializable}; use super::{ValidatorError, ValidatorService}; -use crate::db::{load_chain_tip, load_encryption_key, setup, upsert_block_header}; -use crate::signers::attestation_commitment; +use crate::db::{load_chain_tip, setup, upsert_block_header}; use crate::{LocalX25519TransactionInputDecrypter, TransactionInputDecrypter, ValidatorSigner}; // TEST HELPERS @@ -23,9 +30,66 @@ use crate::{LocalX25519TransactionInputDecrypter, TransactionInputDecrypter, Val const TEST_ENCRYPTION_SECRET: [u8; 32] = [3u8; 32]; /// Creates a [`LocalX25519TransactionInputDecrypter`] from the shared test secret, modelling the -/// identically provisioned encryption master secret of a validator in the set. +/// identically provisioned encryption key of a validator in the set. fn test_decrypter() -> LocalX25519TransactionInputDecrypter { - LocalX25519TransactionInputDecrypter::new(TEST_ENCRYPTION_SECRET) + LocalX25519TransactionInputDecrypter::new( + KeyExchangeKey::read_from_bytes(&TEST_ENCRYPTION_SECRET).unwrap(), + ) +} + +struct FailingScheduleProvider { + inner: LocalX25519TransactionInputDecrypter, + schedule_calls: AtomicUsize, + fail_schedule: AtomicBool, + panic_schedule: AtomicBool, + block_schedule: AtomicBool, + schedule_started: tokio::sync::Notify, + schedule_released: tokio::sync::Notify, +} + +impl FailingScheduleProvider { + fn new() -> Self { + Self { + inner: test_decrypter(), + schedule_calls: AtomicUsize::new(0), + fail_schedule: AtomicBool::new(false), + panic_schedule: AtomicBool::new(false), + block_schedule: AtomicBool::new(false), + schedule_started: tokio::sync::Notify::new(), + schedule_released: tokio::sync::Notify::new(), + } + } +} + +#[tonic::async_trait] +impl TransactionInputDecrypter for FailingScheduleProvider { + async fn encryption_key_schedule( + &self, + chain_tip: BlockNumber, + ) -> anyhow::Result { + self.schedule_calls.fetch_add(1, Ordering::SeqCst); + if self.fail_schedule.load(Ordering::SeqCst) { + anyhow::bail!("schedule provider unavailable"); + } + assert!(!self.panic_schedule.load(Ordering::SeqCst), "schedule provider panicked"); + if self.block_schedule.load(Ordering::SeqCst) { + self.schedule_started.notify_one(); + self.schedule_released.notified().await; + } + self.inner.encryption_key_schedule(chain_tip).await + } + + async fn decrypt_transaction_inputs( + &self, + key_id: &[u8], + chain_tip: BlockNumber, + ciphertext: &[u8], + associated_data: &[u8], + ) -> Result, crate::TransactionInputDecryptionError> { + self.inner + .decrypt_transaction_inputs(key_id, chain_tip, ciphertext, associated_data) + .await + } } /// Test harness that wraps a [`Validator`] and tracks the chain MMR state needed to construct valid @@ -43,22 +107,18 @@ impl TestValidator { /// Creates a correctly configured [`ValidatorService`]: the validator signs blocks with the /// same key that is designated as the `validator_key` in the genesis block. async fn new() -> Self { + Self::new_with_decrypter(Arc::new(test_decrypter())).await + } + + async fn new_with_decrypter(decrypter: Arc) -> Self { let key = random_secret_key(); let signer = ValidatorSigner::new_local(key.clone()); let (temp_dir, db, block_store, genesis_header) = setup_db_with_genesis(&key).await; Self { - server: ValidatorService::new( - signer, - std::sync::Arc::new(test_decrypter()), - db, - block_store, - 0, - 0, - 0, - ) - .await - .unwrap(), + server: ValidatorService::new(signer, decrypter, db, block_store, 0, 0, 0) + .await + .unwrap(), chain: PartialBlockchain::default(), chain_tip: genesis_header, _temp_dir: temp_dir, @@ -732,533 +792,294 @@ async fn requests_run_concurrently() { // TRANSACTION ENCRYPTION KEY // ================================================================================================ -/// The endpoint returns the current and next shared encryption keys, each attested by this -/// validator's own signing key. The signatures verify over commitments recomputed from the response -/// fields and the chain's genesis commitment, so a client needs nothing beyond the response and the -/// chain data it already trusts. +/// The endpoint returns one complete provider schedule attested by this validator. The shared +/// verifier consumes only the response and chain state already trusted by the caller. #[tokio::test] -async fn transaction_encryption_key_is_attested() { +async fn transaction_encryption_key_schedule_is_attested() { let tv = TestValidator::new().await; - // The chain has not advanced, so the chain tip is the genesis header. - let genesis = tv.chain_tip.commitment(); let response = tv.call_get_transaction_encryption_key().await; - - let keys = test_decrypter().encryption_keys(0).await.expect("key info should be available"); - - // The current key matches the epoch-0 derivation and its attestation verifies over the - // current-key commitment. - let current = response.current_key.expect("response must carry the current key"); - let scheme = u32::try_from(current.scheme).expect("scheme must be non-negative"); - assert_eq!(scheme, keys.current.scheme); - assert_eq!(current.key_id, keys.current.key_id); - assert_eq!(current.public_key, keys.current.public_key); - - let commitment = - attestation_commitment(scheme, ¤t.key_id, genesis, ¤t.public_key, None); - assert_eq!(commitment, keys.current.attestation_commitment(genesis)); - - let [attestation] = current.attestations.as_slice() else { - panic!("response must carry exactly the serving validator's attestation"); + let validator_keys = [tv.server.signer.public_key()]; + let trusted = TrustedChainState { + genesis_commitment: tv.chain_tip.commitment(), + chain_tip: tv.chain_tip.block_num(), + validator_keys: &validator_keys, }; - assert_eq!( - attestation.validator_public_key, - tv.server.signer.public_key().to_bytes(), - "attestation must identify the serving validator", - ); - let signature = - Signature::read_from_bytes(&attestation.signature).expect("signature should deserialize"); - assert!( - signature.verify(commitment, &tv.server.signer.public_key()), - "attestation must verify against this validator's signing key", - ); - // The next key matches the epoch-1 derivation, rotates at the first block of epoch 1, and its - // attestation verifies over the next-key commitment binding the rotation block. - let next = response.next_key.expect("a next key must be announced"); - let expected_next = keys.next.expect("epoch 0 must have a next key"); - assert_eq!(next.rotation_block_num, expected_next.rotation_block_num); - assert_eq!(next.rotation_block_num, BlockNumber::from_epoch(1).as_u32()); - - let next_key = next.key.expect("the next key must carry its key material"); - let next_scheme = u32::try_from(next_key.scheme).expect("scheme must be non-negative"); - assert_eq!(next_key.key_id, expected_next.key.key_id); - assert_eq!(next_key.public_key, expected_next.key.public_key); - assert_ne!(next_key.public_key, current.public_key, "the next key must differ"); - - let next_commitment = attestation_commitment( - next_scheme, - &next_key.key_id, - genesis, - &next_key.public_key, - Some(next.rotation_block_num), - ); - assert_eq!(next_commitment, expected_next.attestation_commitment(genesis)); - let next_signature = Signature::read_from_bytes(&next_key.attestations[0].signature) - .expect("signature should deserialize"); - assert!( - next_signature.verify(next_commitment, &tv.server.signer.public_key()), - "the next-key attestation must verify against this validator's signing key", - ); - assert!( - !next_signature.verify( - attestation_commitment( - next_scheme, - &next_key.key_id, - genesis, - &next_key.public_key, - None - ), - &tv.server.signer.public_key(), - ), - "a next-key attestation must not verify as a current-key attestation", - ); + let verified = verify_transaction_encryption_key_schedule(&response, &trusted).unwrap(); + let expected = test_decrypter() + .encryption_key_schedule(tv.chain_tip.block_num()) + .await + .unwrap(); + assert_eq!(verified, expected); + assert_eq!(response.attestations.len(), 1); } -/// Two validators provisioned with the same shared encryption secret but distinct signing keys -/// return identical public key material with different signatures. +/// Validators sharing an encryption provider return the same schedule but attest it with their own +/// chain-recognized signing keys. #[tokio::test] -async fn shared_key_is_attested_per_validator() { +async fn shared_schedule_is_attested_per_validator() { let tv_a = TestValidator::new().await; let tv_b = TestValidator::new().await; - let key_a = tv_a.call_get_transaction_encryption_key().await.current_key.unwrap(); - let key_b = tv_b.call_get_transaction_encryption_key().await.current_key.unwrap(); + let response_a = tv_a.call_get_transaction_encryption_key().await; + let response_b = tv_b.call_get_transaction_encryption_key().await; - 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_eq!(response_a.current_key, response_b.current_key); + assert_eq!( + response_a.current_key_activation_block_num, + response_b.current_key_activation_block_num + ); + assert_eq!(response_a.next_key, response_b.next_key); assert_ne!( - key_a.attestations[0].signature, key_b.attestations[0].signature, + response_a.attestations[0].signature, response_b.attestations[0].signature, "each validator must attest with its own signing key", ); } -/// The attestation signature must not survive tampering with any field of the response, nor a -/// swapped chain. +/// Changing a signed field invalidates the single schedule-level attestation. #[tokio::test] -async fn tampered_attestation_fails_verification() { +async fn tampered_schedule_fails_shared_verification() { let tv = TestValidator::new().await; - let genesis = tv.chain_tip.commitment(); - let response = tv.call_get_transaction_encryption_key().await; - let current = response.current_key.expect("response must carry the current key"); - let signature = Signature::read_from_bytes(¤t.attestations[0].signature).unwrap(); - let signing_key = tv.server.signer.public_key(); - let scheme = u32::try_from(current.scheme).expect("scheme must be non-negative"); - - let mut tampered_public_key = current.public_key.clone(); - tampered_public_key[0] ^= 0x01; - let mut tampered_key_id = current.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 = current.key_id.clone(); - extended_key_id.push(current.public_key[0]); - let tampered_genesis = Word::try_from([9u64, 9, 9, 9]).unwrap(); - - let tampered_commitments = [ - attestation_commitment(scheme + 1, ¤t.key_id, genesis, ¤t.public_key, None), - attestation_commitment(scheme, &tampered_key_id, genesis, ¤t.public_key, None), - attestation_commitment(scheme, &extended_key_id, genesis, ¤t.public_key[1..], None), - attestation_commitment(scheme, ¤t.key_id, genesis, &tampered_public_key, None), - attestation_commitment( - scheme, - ¤t.key_id, - tampered_genesis, - ¤t.public_key, - None, - ), - // Presenting a current-key attestation as a next-key attestation must also break the - // signature, regardless of the claimed rotation block. - attestation_commitment(scheme, ¤t.key_id, genesis, ¤t.public_key, Some(100)), - ]; - for commitment in tampered_commitments { - assert!( - !signature.verify(commitment, &signing_key), - "attestation must not verify over tampered fields", - ); - } + let mut response = tv.call_get_transaction_encryption_key().await; + response.current_key.as_mut().unwrap().key_id[0] ^= 1; + + let validator_keys = [tv.server.signer.public_key()]; + let trusted = TrustedChainState { + genesis_commitment: tv.chain_tip.commitment(), + chain_tip: tv.chain_tip.block_num(), + validator_keys: &validator_keys, + }; + assert!(verify_transaction_encryption_key_schedule(&response, &trusted).is_err()); } -/// A client can reconstruct the sealing key from the response fields and seal a payload that any -/// validator holding the shared secret can unseal. Unsealing must reject mismatched associated -/// data. +/// A fixed provider key remains current across epochs while the validator refreshes only the +/// schedule's freshness attestation. #[tokio::test] -async fn response_key_seals_for_the_validator_set() { - use miden_protocol::crypto::dsa::eddsa_25519_sha512::PublicKey as EncryptionPublicKey; - use miden_protocol::crypto::ies::SealingKey; - +async fn schedule_is_reattested_without_automatic_rotation() { let tv = TestValidator::new().await; - let current = tv - .call_get_transaction_encryption_key() - .await - .current_key - .expect("response must carry the current key"); - - let public_key = EncryptionPublicKey::read_from_bytes(¤t.public_key) - .expect("response public key should deserialize"); - let sealing_key = SealingKey::X25519XChaCha20Poly1305(public_key); - - let mut rng = rand::rng(); - let plaintext = b"transaction inputs"; - let associated_data = b"scheme|key_id|chain|tx"; - let sealed = sealing_key - .seal_bytes_with_associated_data(&mut rng, plaintext, associated_data) - .unwrap(); + let before = tv.call_get_transaction_encryption_key().await; - let sealed = sealed.to_bytes(); - let opened = test_decrypter() - .decrypt_transaction_inputs(0, &sealed, associated_data) - .await - .unwrap(); - assert_eq!(opened.as_slice(), plaintext); + tv.server.committed_tip.send_replace(BlockNumber::from_epoch(1)); + let after = tv.call_get_transaction_encryption_key().await; - assert!( - test_decrypter() - .decrypt_transaction_inputs(0, &sealed, b"other associated data") - .await - .is_err(), - "decryption must fail under mismatched associated data", - ); + assert_eq!(before.current_key, after.current_key); + assert_eq!(before.next_key, after.next_key); + assert_eq!(before.attestation_epoch, 0); + assert_eq!(after.attestation_epoch, 1); + assert_ne!(before.attestations[0].signature, after.attestations[0].signature); + + let validator_keys = [tv.server.signer.public_key()]; + let trusted = TrustedChainState { + genesis_commitment: tv.chain_tip.commitment(), + chain_tip: BlockNumber::from_epoch(1), + validator_keys: &validator_keys, + }; + verify_transaction_encryption_key_schedule(&after, &trusted).unwrap(); + assert!(verify_transaction_encryption_key_schedule(&before, &trusted).is_err()); } -/// Like `status`, the encryption key stays available while a backup subscription holds the -/// exclusive serve lock. +/// A request that waits on the cache lock reads the chain tip after the lock is acquired, so it +/// cannot replace a newer attestation with one for an older epoch. #[tokio::test] -async fn encryption_key_available_during_backup() { - let mut tv = TestValidator::new().await; - tv.apply_empty_block().await; +async fn stale_request_cannot_roll_back_schedule_attestation() { + let tv = TestValidator::new().await; + let epoch_one = ValidatorService::attest_encryption_key_schedule( + tv.server.signer.as_ref(), + tv.server.decrypter.as_ref(), + tv.server.genesis_commitment, + BlockNumber::from_epoch(1), + tv.server.encryption_key_refresh_timeout, + ) + .await + .unwrap(); - let stream = tv.call_block_subscription(1).await; + let mut cached = tv.server.encryption_key_schedule.lock().await; + let mut stale_request = Box::pin(tv.server.attested_encryption_key_schedule()); + tokio::select! { + biased; + _ = &mut stale_request => panic!("request unexpectedly completed"), + () = tokio::task::yield_now() => {}, + } - // `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.current_key.expect("current key must be present").public_key.is_empty()); + tv.server.committed_tip.send_replace(BlockNumber::from_epoch(1)); + cached.attested = Arc::new(epoch_one); + drop(cached); - drop(stream); + let attested = stale_request.await.unwrap(); + assert_eq!(attested.epoch, 1); + assert_eq!(tv.server.encryption_key_schedule.lock().await.attested.epoch, 1); } -/// Crossing an epoch boundary rotates the served key: the previous next key becomes the current key -/// and a fresh next key is announced and attested. +/// A failed epoch refresh is retried only after a request-path backoff, avoiding repeated provider +/// or KMS calls during an outage without introducing a background rotation worker. #[tokio::test] -async fn rotation_task_rotates_keys_at_epoch_boundary() { - use miden_node_utils::shutdown::CancellationToken; - - let tv = TestValidator::new().await; - let genesis = tv.chain_tip.commitment(); - let shutdown = CancellationToken::new(); - let task = tv.server.spawn_key_rotation_task(shutdown.clone()); +async fn failed_schedule_refresh_is_backed_off() { + let provider = Arc::new(FailingScheduleProvider::new()); + let tv = TestValidator::new_with_decrypter(provider.clone()).await; + assert_eq!(provider.schedule_calls.load(Ordering::SeqCst), 1); - let before = tv.call_get_transaction_encryption_key().await; - let announced_next = before.next_key.expect("a next key must be announced").key.unwrap(); - - // A tip advancing within the same epoch must not replace the attested state. The state Arc is - // only swapped on rotation, so pointer identity detects a spurious re-attestation. - let state_before = tv.server.attested_encryption_keys(); - tv.server.committed_tip.send_replace(BlockNumber::from(5u32)); - tokio::time::sleep(std::time::Duration::from_millis(50)).await; - assert!( - std::sync::Arc::ptr_eq(&state_before, &tv.server.attested_encryption_keys()), - "an intra-epoch tip must not re-attest the encryption keys", - ); - - // Crossing into epoch 1 must rotate. The task signs asynchronously, so poll briefly. + provider.fail_schedule.store(true, Ordering::SeqCst); tv.server.committed_tip.send_replace(BlockNumber::from_epoch(1)); - let rotated = tokio::time::timeout(std::time::Duration::from_secs(5), async { - loop { - if tv.server.attested_encryption_keys().epoch == 1 { - break; - } - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - } - }) - .await; - assert!(rotated.is_ok(), "the key must rotate after the epoch boundary"); - let after = tv.call_get_transaction_encryption_key().await; - let current = after.current_key.expect("current key must be present"); + assert!(matches!( + tv.server.attested_encryption_key_schedule().await, + Err(ValidatorError::EncryptionKeyAttestationFailed(_)) + )); + assert!(matches!( + tv.server.attested_encryption_key_schedule().await, + Err(ValidatorError::EncryptionKeyScheduleRefreshBackoff { epoch: 1 }) + )); assert_eq!( - current.public_key, announced_next.public_key, - "the announced next key must become the current key", + provider.schedule_calls.load(Ordering::SeqCst), + 2, + "the initial load and first failed refresh should be the only provider calls", ); - - // The rotated current key carries a fresh, valid current-key attestation. - let scheme = u32::try_from(current.scheme).unwrap(); - let commitment = - attestation_commitment(scheme, ¤t.key_id, genesis, ¤t.public_key, None); - let signature = Signature::read_from_bytes(¤t.attestations[0].signature).unwrap(); - assert!(signature.verify(commitment, &tv.server.signer.public_key())); - - // A fresh next key is announced for epoch 2. - let next = after.next_key.expect("a next key must be announced after rotation"); - assert_eq!(next.rotation_block_num, BlockNumber::from_epoch(2).as_u32()); - assert_ne!(next.key.unwrap().public_key, current.public_key); - - // The rotated epoch's secret key is archived. - let archived = tv - .server - .db - .read("load_encryption_key", |tx| load_encryption_key(tx, 1)) - .await - .unwrap() - .expect("the rotated epoch's key must be archived"); - assert_eq!(archived.secret_key, test_decrypter().key_for_epoch(1).to_bytes()); - assert_eq!(archived.public_key, current.public_key); - - shutdown.cancel(); - task.await.expect("the rotation task must stop on shutdown"); -} - -/// Constructing the service archives the current epoch's secret key, and the archived material -/// matches the deterministic derivation. -#[tokio::test] -async fn startup_archives_current_epoch_key() { - let tv = TestValidator::new().await; - - let archived = tv - .server - .db - .read("load_encryption_key", |tx| load_encryption_key(tx, 0)) - .await - .unwrap() - .expect("the current epoch's key must be archived at startup"); - - let decrypter = test_decrypter(); - assert_eq!(archived.secret_key, decrypter.key_for_epoch(0).to_bytes()); - let info = decrypter.key_info_for_epoch(0); - assert_eq!(archived.scheme, info.scheme); - assert_eq!(archived.key_id, info.key_id); - assert_eq!(archived.public_key, info.public_key); - - // The announced next epoch's key is archived too: clients near a boundary may already seal - // against it. - let archived_next = tv - .server - .db - .read("load_encryption_key", |tx| load_encryption_key(tx, 1)) - .await - .unwrap() - .expect("the announced next epoch's key must be archived at startup"); - assert_eq!(archived_next.secret_key, decrypter.key_for_epoch(1).to_bytes()); } -/// A message sealed against an epoch's public key decrypts with the secret key recovered from the -/// archive alone, proving archived material is sufficient for recovery. #[tokio::test] -async fn archived_secret_key_decrypts_sealed_submissions() { - use miden_protocol::crypto::dsa::eddsa_25519_sha512::KeyExchangeKey; - use miden_protocol::crypto::ies::UnsealingKey; - - let tv = TestValidator::new().await; - let archived = tv - .server - .db - .read("load_encryption_key", |tx| load_encryption_key(tx, 0)) - .await - .unwrap() - .expect("epoch 0 must be archived"); - - let mut rng = rand::rng(); - let plaintext = b"transaction inputs"; - let associated_data = b"associated data"; - let sealed = test_decrypter() - .sealing_key_for_epoch(0) - .seal_bytes_with_associated_data(&mut rng, plaintext, associated_data) - .unwrap(); +async fn panicked_schedule_refresh_is_backed_off_without_wedging_cache() { + let provider = Arc::new(FailingScheduleProvider::new()); + let tv = TestValidator::new_with_decrypter(provider.clone()).await; - let recovered_key = KeyExchangeKey::read_from_bytes(&archived.secret_key) - .expect("archived secret key bytes must form a valid key"); - let opened = UnsealingKey::X25519XChaCha20Poly1305(recovered_key) - .unseal_bytes_with_associated_data(sealed, associated_data) - .expect("the archived secret key must decrypt submissions of its epoch"); - assert_eq!(opened.as_slice(), plaintext); -} + provider.panic_schedule.store(true, Ordering::SeqCst); + tv.server.committed_tip.send_replace(BlockNumber::from_epoch(1)); + assert!(matches!( + tv.server.attested_encryption_key_schedule().await, + Err(ValidatorError::EncryptionKeyAttestationFailed(message)) + if message.contains("refresh task failed") + )); + assert!(matches!( + tv.server.attested_encryption_key_schedule().await, + Err(ValidatorError::EncryptionKeyScheduleRefreshBackoff { epoch: 1 }) + )); -/// [`TransactionInputDecrypter`] test double wrapping the shared test decrypter with a configurable -/// secret key export, modelling failing and non-exporting (e.g. TEE) decrypters. -struct ExportOverrideDecrypter { - inner: LocalX25519TransactionInputDecrypter, - /// While set, `export_secret_key` fails. Toggleable to model a recovering fault. - fail_exports: std::sync::Arc, - /// When set, `export_secret_key` returns `None`, modelling a TEE-held key. - export_unavailable: bool, + provider.panic_schedule.store(false, Ordering::SeqCst); + tv.server.committed_tip.send_replace(BlockNumber::from_epoch(2)); + tv.server.attested_encryption_key_schedule().await.unwrap(); + assert_eq!(provider.schedule_calls.load(Ordering::SeqCst), 3); } -impl ExportOverrideDecrypter { - fn new(fail_exports: bool, export_unavailable: bool) -> Self { - Self { - inner: test_decrypter(), - fail_exports: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(fail_exports)), - export_unavailable, - } - } -} +/// A client cancellation does not penalize the next request. +#[tokio::test] +async fn cancelled_schedule_refresh_allows_immediate_retry() { + let provider = Arc::new(FailingScheduleProvider::new()); + let tv = TestValidator::new_with_decrypter(provider.clone()).await; -#[tonic::async_trait] -impl TransactionInputDecrypter for ExportOverrideDecrypter { - async fn encryption_keys(&self, epoch: u16) -> anyhow::Result { - self.inner.encryption_keys(epoch).await - } + provider.block_schedule.store(true, Ordering::SeqCst); + tv.server.committed_tip.send_replace(BlockNumber::from_epoch(1)); - async fn decrypt_transaction_inputs( - &self, - epoch: u16, - ciphertext: &[u8], - associated_data: &[u8], - ) -> anyhow::Result> { - self.inner.decrypt_transaction_inputs(epoch, ciphertext, associated_data).await + let mut refresh = Box::pin(tv.server.attested_encryption_key_schedule()); + tokio::select! { + () = provider.schedule_started.notified() => {}, + _ = &mut refresh => panic!("refresh unexpectedly completed"), } + drop(refresh); - async fn export_secret_key(&self, epoch: u16) -> anyhow::Result>> { - if self.fail_exports.load(std::sync::atomic::Ordering::Relaxed) { - anyhow::bail!("export unavailable"); - } - if self.export_unavailable { - return Ok(None); - } - self.inner.export_secret_key(epoch).await - } + provider.block_schedule.store(false, Ordering::SeqCst); + provider.schedule_released.notify_one(); + tv.server.attested_encryption_key_schedule().await.unwrap(); + assert_eq!( + provider.schedule_calls.load(Ordering::SeqCst), + 2, + "the successful retry must share the refresh started by the cancelled request", + ); } -/// A decrypter whose secret key export fails must fail service construction with an archival error, -/// since a served key that was never archived would silently void the archive guarantee. #[tokio::test] -async fn failing_secret_key_export_fails_construction() { - 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; - let decrypter = ExportOverrideDecrypter::new(true, false); - - let result = - ValidatorService::new(signer, std::sync::Arc::new(decrypter), db, block_store, 0, 0, 0) - .await; +async fn failed_signer_refresh_is_backed_off() { + let mut tv = TestValidator::new().await; + let public_key = tv.server.signer.public_key(); + tv.server.signer = Arc::new(ValidatorSigner::new_failing(public_key)); + tv.server.committed_tip.send_replace(BlockNumber::from_epoch(1)); - assert!( - matches!(result, Err(ValidatorError::EncryptionKeyArchivalFailed(_))), - "construction must surface the archival failure", - ); + assert!(matches!( + tv.server.attested_encryption_key_schedule().await, + Err(ValidatorError::EncryptionKeyAttestationFailed(_)) + )); + assert!(matches!( + tv.server.attested_encryption_key_schedule().await, + Err(ValidatorError::EncryptionKeyScheduleRefreshBackoff { epoch: 1 }) + )); } -/// A decrypter that cannot export secrets (e.g. a TEE-held key) skips archival entirely without -/// failing construction. #[tokio::test] -async fn non_exporting_decrypter_skips_archival() { - 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; - let decrypter = ExportOverrideDecrypter::new(false, true); - - let server = - ValidatorService::new(signer, std::sync::Arc::new(decrypter), db, block_store, 0, 0, 0) - .await - .expect("a non-exporting decrypter must not fail construction"); +async fn schedule_lookup_timeout_is_backed_off() { + let provider = Arc::new(FailingScheduleProvider::new()); + let mut tv = TestValidator::new_with_decrypter(provider.clone()).await; + tv.server.encryption_key_refresh_timeout = Duration::from_millis(1); + provider.block_schedule.store(true, Ordering::SeqCst); + tv.server.committed_tip.send_replace(BlockNumber::from_epoch(1)); - let archived = server - .db - .read("load_encryption_key", |tx| load_encryption_key(tx, 0)) - .await - .unwrap(); - assert_eq!(archived, None, "no key must be archived when the decrypter cannot export"); + assert!(matches!( + tv.server.attested_encryption_key_schedule().await, + Err(ValidatorError::EncryptionKeyScheduleRefreshTimedOut { operation: "loading" }) + )); + assert!(matches!( + tv.server.attested_encryption_key_schedule().await, + Err(ValidatorError::EncryptionKeyScheduleRefreshBackoff { epoch: 1 }) + )); + assert_eq!(provider.schedule_calls.load(Ordering::SeqCst), 2); } -/// A rotation that fails at the epoch boundary keeps serving the previous epoch's keys and -/// completes once the fault clears, via the retry timer. #[tokio::test] -async fn failed_rotation_retries_until_it_succeeds() { - use miden_node_utils::shutdown::CancellationToken; - - 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; - let decrypter = ExportOverrideDecrypter::new(false, false); - let fail_exports = std::sync::Arc::clone(&decrypter.fail_exports); +async fn schedule_signing_timeout_is_backed_off() { + let mut tv = TestValidator::new().await; + tv.server.encryption_key_refresh_timeout = Duration::from_millis(1); + let public_key = tv.server.signer.public_key(); + tv.server.signer = Arc::new(ValidatorSigner::new_blocking(public_key)); + tv.server.committed_tip.send_replace(BlockNumber::from_epoch(1)); - let server = - ValidatorService::new(signer, std::sync::Arc::new(decrypter), db, block_store, 0, 0, 0) - .await - .unwrap(); - let shutdown = CancellationToken::new(); - let task = tokio::spawn(ValidatorService::key_rotation_loop( - std::sync::Arc::clone(&server.signer), - std::sync::Arc::clone(&server.decrypter), - std::sync::Arc::clone(&server.encryption_keys), - std::sync::Arc::clone(&server.db), - server.genesis_commitment, - server.committed_tip.subscribe(), - std::time::Duration::from_millis(20), - shutdown.clone(), + assert!(matches!( + tv.server.attested_encryption_key_schedule().await, + Err(ValidatorError::EncryptionKeyScheduleRefreshTimedOut { operation: "signing" }) + )); + assert!(matches!( + tv.server.attested_encryption_key_schedule().await, + Err(ValidatorError::EncryptionKeyScheduleRefreshBackoff { epoch: 1 }) )); - - // Cross the boundary while archival is failing: the previous epoch's keys stay served through - // several retry cycles. - fail_exports.store(true, std::sync::atomic::Ordering::Relaxed); - server.committed_tip.send_replace(BlockNumber::from_epoch(1)); - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - assert_eq!( - server.attested_encryption_keys().epoch, - 0, - "a failing rotation must keep serving the previous epoch's keys", - ); - - // Once the fault clears, the retry timer completes the rotation without new blocks. - fail_exports.store(false, std::sync::atomic::Ordering::Relaxed); - let rotated = tokio::time::timeout(std::time::Duration::from_secs(5), async { - loop { - if server.attested_encryption_keys().epoch == 1 { - break; - } - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - } - }) - .await; - assert!(rotated.is_ok(), "the rotation must complete after the fault clears"); - - shutdown.cancel(); - task.await.expect("the rotation loop must stop on shutdown"); } -/// A tip skipping several epochs at once (e.g. a validator catching up) rotates directly to the -/// tip's epoch. +/// A client can reconstruct the sealing key from the response and the provider can decrypt the +/// ciphertext selected by the caller-supplied opaque key id. #[tokio::test] -async fn rotation_task_catches_up_across_multiple_epochs() { - use miden_node_utils::shutdown::CancellationToken; +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 shutdown = CancellationToken::new(); - let task = tv.server.spawn_key_rotation_task(shutdown.clone()); - - tv.server.committed_tip.send_replace(BlockNumber::from_epoch(3)); - let rotated = tokio::time::timeout(std::time::Duration::from_secs(5), async { - loop { - if tv.server.attested_encryption_keys().epoch == 3 { - break; - } - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - } - }) - .await; - assert!(rotated.is_ok(), "the key must rotate directly to the tip's epoch"); + let response = tv.call_get_transaction_encryption_key().await; + let current = response.current_key.unwrap(); - let expected = test_decrypter().encryption_keys(3).await.expect("key info should be available"); - let current = tv - .call_get_transaction_encryption_key() + let public_key = EncryptionPublicKey::read_from_bytes(¤t.public_key) + .expect("response public key should deserialize"); + let sealing_key = SealingKey::X25519XChaCha20Poly1305(public_key); + let associated_data = b"scheme|key_id|chain|tx"; + let sealed = sealing_key + .seal_bytes_with_associated_data(&mut rand::rng(), b"transaction inputs", associated_data) + .unwrap() + .to_bytes(); + + let opened = test_decrypter() + .decrypt_transaction_inputs( + ¤t.key_id, + tv.chain_tip.block_num(), + &sealed, + associated_data, + ) .await - .current_key - .expect("current key must be present"); - assert_eq!(current.public_key, expected.current.public_key); - - // Every skipped epoch is backfilled into the archive, including the announced next epoch. - for epoch in 0..=4u16 { - let archived = tv - .server - .db - .read("load_encryption_key", move |tx| load_encryption_key(tx, epoch)) - .await - .unwrap() - .unwrap_or_else(|| panic!("epoch {epoch} must be archived")); - assert_eq!(archived.secret_key, test_decrypter().key_for_epoch(epoch).to_bytes()); - } + .unwrap(); + assert_eq!(opened, b"transaction inputs"); +} + +/// Like status, the encryption key remains available during an exclusive backup subscription. +#[tokio::test] +async fn encryption_key_available_during_backup() { + let mut tv = TestValidator::new().await; + tv.apply_empty_block().await; + let stream = tv.call_block_subscription(1).await; - shutdown.cancel(); - task.await.expect("the rotation task must stop on shutdown"); + let response = tv.call_get_transaction_encryption_key().await; + assert!(!response.current_key.unwrap().public_key.is_empty()); + + drop(stream); } diff --git a/bin/validator/src/signers/mod.rs b/bin/validator/src/signers/mod.rs index 0b1f1554ec..4075141634 100644 --- a/bin/validator/src/signers/mod.rs +++ b/bin/validator/src/signers/mod.rs @@ -1,16 +1,22 @@ mod kms; -pub use kms::{KmsSigner, decrypt_key_material}; + +pub use miden_node_proto::domain::transaction_encryption::{ + NextTransactionEncryptionKey, + TransactionEncryptionKeyInfo, + TransactionEncryptionKeySchedule, +}; use miden_node_utils::spawn::spawn_blocking_in_current_span; use miden_protocol::Word; use miden_protocol::block::BlockNumber; use miden_protocol::crypto::dsa::ecdsa_k256_keccak::{PublicKey, Signature, SigningKey}; use miden_protocol::crypto::dsa::eddsa_25519_sha512::KeyExchangeKey; -use miden_protocol::crypto::hash::blake::Blake3_256; #[cfg(test)] use miden_protocol::crypto::ies::SealingKey; use miden_protocol::crypto::ies::{IesScheme, SealedMessage, UnsealingKey}; use miden_protocol::utils::serde::{Deserializable, Serializable}; +pub use self::kms::{KmsSigner, decrypt_key_material}; + // VALIDATOR SIGNER // ================================================================================================= @@ -18,6 +24,10 @@ use miden_protocol::utils::serde::{Deserializable, Serializable}; pub enum ValidatorSigner { Kms(KmsSigner), Local(SigningKey), + #[cfg(test)] + Failing(PublicKey), + #[cfg(test)] + Blocking(PublicKey), } impl ValidatorSigner { @@ -30,16 +40,28 @@ impl ValidatorSigner { Ok(Self::Kms(kms_signer)) } - /// Constructs a signer which uses a local secret key for signing. + /// Constructs a signer which uses a local secret key. pub fn new_local(secret_key: SigningKey) -> Self { Self::Local(secret_key) } + #[cfg(test)] + pub(crate) fn new_failing(public_key: PublicKey) -> Self { + Self::Failing(public_key) + } + + #[cfg(test)] + pub(crate) fn new_blocking(public_key: PublicKey) -> Self { + Self::Blocking(public_key) + } + /// Returns the public key corresponding to the configured signer. pub fn public_key(&self) -> PublicKey { match self { Self::Kms(signer) => signer.public_key(), Self::Local(signer) => signer.public_key(), + #[cfg(test)] + Self::Failing(public_key) | Self::Blocking(public_key) => public_key.clone(), } } @@ -53,6 +75,10 @@ impl ValidatorSigner { }) .await .unwrap_or_else(|e| std::panic::resume_unwind(e.into_panic())), + #[cfg(test)] + Self::Failing(_) => anyhow::bail!("test signer unavailable"), + #[cfg(test)] + Self::Blocking(_) => std::future::pending::().await, }; Ok(signature) @@ -62,195 +88,169 @@ impl ValidatorSigner { // TRANSACTION INPUT DECRYPTER // ================================================================================================= -/// Domain tag prefixed to the attestation payload, separating key attestations from block header -/// signatures made with the same validator key. -pub const ATTESTATION_DOMAIN: &[u8] = b"MIDEN_TX_ENCRYPTION_KEY_ATTESTATION_V1"; - -/// Domain tag prefixed to the per-epoch key derivation payload, separating derived encryption key -/// seeds from any other use of the shared master secret. -pub const KEY_DERIVATION_DOMAIN: &[u8] = b"MIDEN_TX_ENCRYPTION_KEY_DERIVATION_V1"; - -/// Decryption counterpart to [`ValidatorSigner`] for the shared transaction encryption -/// (submission) key. -/// -/// Unlike the signing key, the key material behind an implementation must be identical across -/// every validator in the set. This lets any validator unseal an encrypted submission, regardless -/// of which validator attested the encryption key to the client. -/// -/// The encryption key rotates every epoch. An implementation derives the key for any epoch from -/// its shared key material, so all validators transition to the same new key at each epoch -/// boundary without coordination. +/// Operation-only provider for transaction input encryption keys. /// -/// The interface deliberately does not assume that secret key bytes exist in the validator -/// process: an implementation may hold a local secret (see -/// [`LocalX25519TransactionInputDecrypter`]) or delegate decryption to an external system such as -/// a TEE that only exposes a decrypt operation. +/// Implementations own key identifiers, scheduling, grace policy, and secret storage. The +/// validator only requests public schedule metadata and asks the provider to decrypt with the key +/// identifier carried by a submission. #[tonic::async_trait] pub trait TransactionInputDecrypter: Send + Sync { - /// Returns the public metadata of the encryption key for the given epoch, together with the key - /// scheduled to replace it at the next epoch boundary. - async fn encryption_keys(&self, epoch: u16) -> anyhow::Result; - - /// Decrypts transaction inputs sealed against the encryption key of the given epoch. + /// Returns the schedule effective at `chain_tip`. /// - /// Implementations should fall back to the previous epoch's key when unsealing with the - /// given epoch's key fails, granting a one-epoch grace window to submissions sealed just - /// before a rotation. + /// Providers must keep this schedule unchanged within an epoch. Manual schedule updates happen + /// at epoch boundaries so an older attestation from the same epoch cannot suppress a newly + /// announced key. + async fn encryption_key_schedule( + &self, + chain_tip: BlockNumber, + ) -> anyhow::Result; + + /// Decrypts inputs using the caller-supplied opaque key identifier. /// - /// The ciphertext is a serialized [`SealedMessage`]. + /// The provider must distinguish an announced key that is not active yet, an expired grace + /// key, and an identifier it does not own. async fn decrypt_transaction_inputs( &self, - epoch: u16, + key_id: &[u8], + chain_tip: BlockNumber, ciphertext: &[u8], associated_data: &[u8], - ) -> anyhow::Result>; - - /// Returns the secret key material of the given epoch's encryption key for archival, or `None` - /// when the implementation cannot export secret key bytes (e.g. a TEE-held key) and handles its - /// own archival. - async fn export_secret_key(&self, epoch: u16) -> anyhow::Result>>; + ) -> Result, TransactionInputDecryptionError>; } -/// Public metadata of a 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 encryption key. - pub key_id: Vec, - /// Raw public key bytes of the shared encryption key. - pub public_key: Vec, +#[derive(Debug, thiserror::Error)] +pub enum TransactionInputDecryptionError { + #[error("transaction encryption key {key_id} does not activate until block {activation}")] + PrematureKey { key_id: String, activation: BlockNumber }, + #[error("transaction encryption key {key_id} expired at block {expired_at}")] + ExpiredKey { key_id: String, expired_at: BlockNumber }, + #[error("unknown transaction encryption key {key_id}")] + UnknownKey { key_id: String }, + #[error("failed to deserialize the sealed transaction inputs")] + InvalidCiphertext(#[source] anyhow::Error), + #[error("failed to decrypt the transaction inputs")] + DecryptionFailed(#[source] anyhow::Error), } -/// Public metadata of the next transaction encryption key, announced ahead of its scheduled -/// rotation, in wire format. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct NextEncryptionKeyInfo { - /// The key that replaces the current one at the rotation block. - pub key: TransactionEncryptionKeyInfo, - /// Block number at which the next key replaces the current one. - pub rotation_block_num: u32, +#[derive(Clone)] +struct LocalEncryptionKey { + secret_key: KeyExchangeKey, + info: TransactionEncryptionKeyInfo, } -/// The encryption key of one epoch together with its scheduled replacement. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct EncryptionKeySet { - /// The key in effect during the epoch. - pub current: TransactionEncryptionKeyInfo, - /// The key that takes over at the next epoch boundary. `None` only when no next epoch exists - /// (the epoch counter is saturated). - pub next: Option, -} - -impl TransactionEncryptionKeyInfo { - /// Returns the commitment signed by a validator to attest this key as the current encryption - /// key. - pub fn attestation_commitment(&self, genesis_commitment: Word) -> Word { - attestation_commitment( - self.scheme, - &self.key_id, - genesis_commitment, - &self.public_key, - None, - ) - } -} - -impl NextEncryptionKeyInfo { - /// Returns the commitment signed by a validator to attest this key as the next encryption key, - /// taking effect at the rotation block. - pub fn attestation_commitment(&self, genesis_commitment: Word) -> Word { - attestation_commitment( - self.key.scheme, - &self.key.key_id, - genesis_commitment, - &self.key.public_key, - Some(self.rotation_block_num), - ) +impl LocalEncryptionKey { + fn new(secret_key: KeyExchangeKey) -> Self { + let public_key = secret_key.public_key(); + let info = TransactionEncryptionKeyInfo { + scheme: LocalX25519TransactionInputDecrypter::scheme_id(), + key_id: public_key.to_commitment().to_bytes(), + public_key: public_key.to_bytes(), + }; + Self { secret_key, info } } } -/// 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 || role_suffix`, binding every field of -/// the attested key 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. -/// -/// `role_suffix` is a single `0` byte when the key is attested as the current key, or a `1` byte -/// followed by the rotation block number (4 bytes little-endian) when the key is attested as a -/// scheduled next key. Separating the roles means a next-key attestation cannot be presented as -/// a current-key attestation (or vice versa), and the rotation block cannot be altered without -/// invalidating the signature. -pub fn attestation_commitment( - scheme: u32, - key_id: &[u8], - genesis_commitment: Word, - public_key: &[u8], - rotation_block_num: Option, -) -> Word { - let genesis_commitment = genesis_commitment.to_bytes(); - let mut payload = Vec::with_capacity( - ATTESTATION_DOMAIN.len() - + 4 * size_of::() - + key_id.len() - + genesis_commitment.len() - + public_key.len() - + 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"); - 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) +#[derive(Clone)] +struct ScheduledLocalEncryptionKey { + key: LocalEncryptionKey, + activation_block_num: BlockNumber, } -/// Appends a field to the attestation payload prefixed with its length as 4 bytes little-endian. +/// Local X25519 provider with an optional manually scheduled replacement key. /// -/// 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 shared master secret, from which -/// the per-epoch X25519 keys are derived. +/// Constructing this provider does not derive keys or choose a rotation cadence. When a next key +/// is configured, its declared epoch-boundary activation is enforced from the trusted chain tip. +/// One previous key may be retained for decryption through the current key's activation epoch. pub struct LocalX25519TransactionInputDecrypter { - master_secret: [u8; 32], + previous: Option, + current: ScheduledLocalEncryptionKey, + next: Option, } impl LocalX25519TransactionInputDecrypter { /// The IES scheme used for transaction input encryption. pub const SCHEME: IesScheme = IesScheme::X25519XChaCha20Poly1305; - /// Constructs a decrypter from a locally provisioned shared master secret. - /// - /// The master secret is never used as an encryption key directly. The key for each epoch is - /// derived from it, so every validator provisioned with the same secret derives the same - /// per-epoch keys. - pub fn new(master_secret: [u8; 32]) -> Self { - Self { master_secret } + /// Constructs a provider with one key active since genesis and no scheduled rotation. + pub fn new(secret_key: KeyExchangeKey) -> Self { + Self { + previous: None, + current: ScheduledLocalEncryptionKey { + key: LocalEncryptionKey::new(secret_key), + activation_block_num: BlockNumber::GENESIS, + }, + next: None, + } + } + + /// Constructs a complete manual key schedule for startup. + pub fn from_schedule( + previous: Option<(KeyExchangeKey, BlockNumber)>, + current: (KeyExchangeKey, BlockNumber), + next: Option<(KeyExchangeKey, BlockNumber)>, + ) -> anyhow::Result { + let previous = previous + .map(|(key, activation_block_num)| scheduled_local_key(key, activation_block_num)) + .transpose()?; + let current = scheduled_local_key(current.0, current.1)?; + let next = next + .map(|(key, activation_block_num)| scheduled_local_key(key, activation_block_num)) + .transpose()?; + + if let Some(previous) = &previous { + anyhow::ensure!( + previous.activation_block_num < current.activation_block_num, + "previous key activation must be before current key activation" + ); + anyhow::ensure!( + previous.key.info.key_id != current.key.info.key_id, + "previous and current keys must have distinct ids" + ); + } + if let Some(next) = &next { + anyhow::ensure!( + current.activation_block_num < next.activation_block_num, + "next key activation must be after current key activation" + ); + anyhow::ensure!( + current.key.info.key_id != next.key.info.key_id, + "current and next keys must have distinct ids" + ); + anyhow::ensure!( + previous + .as_ref() + .is_none_or(|previous| previous.key.info.key_id != next.key.info.key_id), + "previous and next keys must have distinct ids" + ); + } + + Ok(Self { previous, current, next }) + } + + /// Adds a manually chosen replacement key at a future epoch boundary. + pub fn with_scheduled_rotation( + mut self, + secret_key: KeyExchangeKey, + activation_block_num: BlockNumber, + ) -> anyhow::Result { + ensure_epoch_boundary(activation_block_num)?; + anyhow::ensure!( + activation_block_num > self.current.activation_block_num, + "next key activation must be after current key activation" + ); + let next = LocalEncryptionKey::new(secret_key); + anyhow::ensure!( + next.info.key_id != self.current.key.info.key_id, + "current and next keys must have distinct ids" + ); + anyhow::ensure!( + self.previous + .as_ref() + .is_none_or(|previous| previous.key.info.key_id != next.info.key_id), + "previous and next keys must have distinct ids" + ); + self.next = Some(ScheduledLocalEncryptionKey { key: next, activation_block_num }); + Ok(self) } /// Returns the wire representation of [`Self::SCHEME`]. @@ -258,94 +258,200 @@ impl LocalX25519TransactionInputDecrypter { u32::from(u8::from(Self::SCHEME)) } - /// Derives the encryption key for the given epoch. - /// - /// The key seed is `blake3(KEY_DERIVATION_DOMAIN || master_secret || epoch)`, with the epoch - /// encoded as 2 bytes little-endian. - pub fn key_for_epoch(&self, epoch: u16) -> KeyExchangeKey { - let mut payload = - Vec::with_capacity(KEY_DERIVATION_DOMAIN.len() + self.master_secret.len() + 2); - payload.extend_from_slice(KEY_DERIVATION_DOMAIN); - payload.extend_from_slice(&self.master_secret); - payload.extend_from_slice(&epoch.to_le_bytes()); - let seed = Blake3_256::hash(&payload); - KeyExchangeKey::read_from_bytes(seed.as_bytes()) - .expect("a 32-byte seed always forms a valid key exchange key") - } - - /// Returns the public metadata of the encryption key for the given epoch. - /// - /// The key id is the first 4 bytes of the public key commitment. - pub fn key_info_for_epoch(&self, epoch: u16) -> TransactionEncryptionKeyInfo { - let public_key = self.key_for_epoch(epoch).public_key(); - TransactionEncryptionKeyInfo { - scheme: Self::scheme_id(), - key_id: public_key.to_commitment().to_bytes()[..4].to_vec(), - public_key: public_key.to_bytes(), - } + #[cfg(test)] + fn sealing_key(&self) -> SealingKey { + SealingKey::X25519XChaCha20Poly1305(self.current.key.secret_key.public_key()) } - /// Returns the sealing key that clients use to encrypt messages to the validator set during the - /// given epoch. #[cfg(test)] - pub fn sealing_key_for_epoch(&self, epoch: u16) -> SealingKey { - SealingKey::X25519XChaCha20Poly1305(self.key_for_epoch(epoch).public_key()) + fn previous_sealing_key(&self) -> Option { + self.previous.as_ref().map(|previous| { + SealingKey::X25519XChaCha20Poly1305(previous.key.secret_key.public_key()) + }) } - /// Attempts to unseal a message with the key of a single epoch. - fn unseal_with_epoch_key( - &self, - epoch: u16, + #[cfg(test)] + fn next_sealing_key(&self) -> Option { + self.next + .as_ref() + .map(|next| SealingKey::X25519XChaCha20Poly1305(next.key.secret_key.public_key())) + } + + fn unseal( + key: &LocalEncryptionKey, message: SealedMessage, associated_data: &[u8], - ) -> anyhow::Result> { + ) -> Result, TransactionInputDecryptionError> { use anyhow::Context; - UnsealingKey::X25519XChaCha20Poly1305(self.key_for_epoch(epoch)) + UnsealingKey::X25519XChaCha20Poly1305(key.secret_key.clone()) .unseal_bytes_with_associated_data(message, associated_data) - .context("failed to unseal the transaction inputs") + .context("failed to unseal with the selected transaction encryption key") + .map_err(TransactionInputDecryptionError::DecryptionFailed) + } + + fn key_for_decryption( + &self, + key_id: &[u8], + chain_tip: BlockNumber, + ) -> Result<&LocalEncryptionKey, TransactionInputDecryptionError> { + if let Some(previous) = &self.previous + && key_id == previous.key.info.key_id + { + if chain_tip < previous.activation_block_num { + return Err(TransactionInputDecryptionError::PrematureKey { + key_id: hex::encode(key_id), + activation: previous.activation_block_num, + }); + } + if let Some(grace_expiry) = self + .current + .activation_block_num + .block_epoch() + .checked_add(1) + .map(BlockNumber::from_epoch) + && chain_tip >= grace_expiry + { + return Err(TransactionInputDecryptionError::ExpiredKey { + key_id: hex::encode(key_id), + expired_at: grace_expiry, + }); + } + return Ok(&previous.key); + } + + if key_id == self.current.key.info.key_id { + if chain_tip < self.current.activation_block_num { + return Err(TransactionInputDecryptionError::PrematureKey { + key_id: hex::encode(key_id), + activation: self.current.activation_block_num, + }); + } + + if let Some(next) = &self.next + && chain_tip >= next.activation_block_num + && let Some(grace_expiry) = next + .activation_block_num + .block_epoch() + .checked_add(1) + .map(BlockNumber::from_epoch) + && chain_tip >= grace_expiry + { + return Err(TransactionInputDecryptionError::ExpiredKey { + key_id: hex::encode(key_id), + expired_at: grace_expiry, + }); + } + + return Ok(&self.current.key); + } + + if let Some(next) = &self.next + && key_id == next.key.info.key_id + { + if chain_tip < next.activation_block_num { + return Err(TransactionInputDecryptionError::PrematureKey { + key_id: hex::encode(key_id), + activation: next.activation_block_num, + }); + } + return Ok(&next.key); + } + + Err(TransactionInputDecryptionError::UnknownKey { key_id: hex::encode(key_id) }) } } #[tonic::async_trait] impl TransactionInputDecrypter for LocalX25519TransactionInputDecrypter { - async fn encryption_keys(&self, epoch: u16) -> anyhow::Result { - let next = epoch.checked_add(1).map(|next_epoch| NextEncryptionKeyInfo { - key: self.key_info_for_epoch(next_epoch), - rotation_block_num: BlockNumber::from_epoch(next_epoch).as_u32(), - }); - - Ok(EncryptionKeySet { - current: self.key_info_for_epoch(epoch), - next, + async fn encryption_key_schedule( + &self, + chain_tip: BlockNumber, + ) -> anyhow::Result { + let previous_is_required = self.current.activation_block_num != BlockNumber::GENESIS + && self + .current + .activation_block_num + .block_epoch() + .checked_add(1) + .map(BlockNumber::from_epoch) + .is_none_or(|grace_expiry| chain_tip < grace_expiry); + anyhow::ensure!( + !previous_is_required || self.previous.is_some(), + "a previous key is required through the current key's activation epoch" + ); + + if chain_tip < self.current.activation_block_num { + let previous = self.previous.as_ref().ok_or_else(|| { + anyhow::anyhow!( + "current key does not activate until block {} and no previous key is configured", + self.current.activation_block_num + ) + })?; + return Ok(TransactionEncryptionKeySchedule { + current_key: previous.key.info.clone(), + current_key_activation_block_num: previous.activation_block_num, + next_key: Some(NextTransactionEncryptionKey { + key: self.current.key.info.clone(), + activation_block_num: self.current.activation_block_num, + }), + }); + } + + if let Some(next) = &self.next + && chain_tip >= next.activation_block_num + { + return Ok(TransactionEncryptionKeySchedule { + current_key: next.key.info.clone(), + current_key_activation_block_num: next.activation_block_num, + next_key: None, + }); + } + + Ok(TransactionEncryptionKeySchedule { + current_key: self.current.key.info.clone(), + current_key_activation_block_num: self.current.activation_block_num, + next_key: self.next.as_ref().map(|next| NextTransactionEncryptionKey { + key: next.key.info.clone(), + activation_block_num: next.activation_block_num, + }), }) } async fn decrypt_transaction_inputs( &self, - epoch: u16, + key_id: &[u8], + chain_tip: BlockNumber, ciphertext: &[u8], associated_data: &[u8], - ) -> anyhow::Result> { + ) -> Result, TransactionInputDecryptionError> { use anyhow::Context; let message = SealedMessage::read_from_bytes(ciphertext) - .context("failed to deserialize the sealed message")?; - - match self.unseal_with_epoch_key(epoch, message.clone(), associated_data) { - Ok(plaintext) => Ok(plaintext), - Err(err) => match epoch.checked_sub(1) { - Some(previous_epoch) => { - self.unseal_with_epoch_key(previous_epoch, message, associated_data) - }, - None => Err(err), - }, - } + .context("failed to deserialize the sealed message") + .map_err(TransactionInputDecryptionError::InvalidCiphertext)?; + let key = self.key_for_decryption(key_id, chain_tip)?; + Self::unseal(key, message, associated_data) } +} - async fn export_secret_key(&self, epoch: u16) -> anyhow::Result>> { - Ok(Some(self.key_for_epoch(epoch).to_bytes())) - } +fn scheduled_local_key( + secret_key: KeyExchangeKey, + activation_block_num: BlockNumber, +) -> anyhow::Result { + ensure_epoch_boundary(activation_block_num)?; + Ok(ScheduledLocalEncryptionKey { + key: LocalEncryptionKey::new(secret_key), + activation_block_num, + }) +} + +fn ensure_epoch_boundary(block_num: BlockNumber) -> anyhow::Result<()> { + anyhow::ensure!( + BlockNumber::from_epoch(block_num.block_epoch()) == block_num, + "key activation block must be an epoch boundary" + ); + Ok(()) } // TESTS @@ -357,148 +463,397 @@ mod tests { use super::*; - fn decrypter_from(secret: &[u8; 32]) -> LocalX25519TransactionInputDecrypter { - LocalX25519TransactionInputDecrypter::new(*secret) + fn key(seed: u8) -> KeyExchangeKey { + KeyExchangeKey::read_from_bytes(&[seed; 32]).unwrap() } - /// Loading the same master secret must yield the same per-epoch key metadata and attestation - /// commitments 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 keys_a = decrypter_from(&[7u8; 32]).encryption_keys(3).await.unwrap(); - let keys_b = decrypter_from(&[7u8; 32]).encryption_keys(3).await.unwrap(); + fn decrypter(seed: u8) -> LocalX25519TransactionInputDecrypter { + LocalX25519TransactionInputDecrypter::new(key(seed)) + } - assert_eq!(keys_a, keys_b); - assert_eq!( - keys_a.current.attestation_commitment(genesis), - keys_b.current.attestation_commitment(genesis) - ); + fn scheduled_decrypter() -> LocalX25519TransactionInputDecrypter { + decrypter(7) + .with_scheduled_rotation(key(8), BlockNumber::from_epoch(1)) + .unwrap() + } + + fn repeated_rotation_decrypter() -> LocalX25519TransactionInputDecrypter { + LocalX25519TransactionInputDecrypter::from_schedule( + Some((key(7), BlockNumber::GENESIS)), + (key(8), BlockNumber::from_epoch(1)), + Some((key(9), BlockNumber::from_epoch(2))), + ) + .unwrap() + } + + fn seal(sealing_key: &SealingKey, plaintext: &[u8], associated_data: &[u8]) -> Vec { + sealing_key + .seal_bytes_with_associated_data(&mut rng(), plaintext, associated_data) + .unwrap() + .to_bytes() + } + + fn repeated_rotation_ciphertexts( + decrypter: &LocalX25519TransactionInputDecrypter, + associated_data: &[u8], + ) -> (Vec, Vec, Vec) { + ( + seal(&decrypter.previous_sealing_key().unwrap(), b"previous", associated_data), + seal(&decrypter.sealing_key(), b"current", associated_data), + seal(&decrypter.next_sealing_key().unwrap(), b"next", associated_data), + ) + } + + async fn assert_decrypts( + decrypter: &LocalX25519TransactionInputDecrypter, + key_id: &[u8], + chain_tip: BlockNumber, + ciphertext: &[u8], + associated_data: &[u8], + expected: &[u8], + ) { assert_eq!( - keys_a.next.as_ref().unwrap().attestation_commitment(genesis), - keys_b.next.as_ref().unwrap().attestation_commitment(genesis) + decrypter + .decrypt_transaction_inputs(key_id, chain_tip, ciphertext, associated_data,) + .await + .unwrap(), + expected ); } - /// Different master secrets must yield different public keys and key ids. - #[tokio::test] - async fn different_secrets_yield_different_public_material() { - let genesis = Word::try_from([1u64, 2, 3, 4]).unwrap(); - let keys_a = decrypter_from(&[7u8; 32]).encryption_keys(3).await.unwrap(); - let keys_b = decrypter_from(&[8u8; 32]).encryption_keys(3).await.unwrap(); - - assert_eq!(keys_a.current.scheme, keys_b.current.scheme); - assert_ne!(keys_a.current.public_key, keys_b.current.public_key); - assert_ne!(keys_a.current.key_id, keys_b.current.key_id); - assert_ne!( - keys_a.current.attestation_commitment(genesis), - keys_b.current.attestation_commitment(genesis) - ); + async fn assert_premature( + decrypter: &LocalX25519TransactionInputDecrypter, + key_id: &[u8], + chain_tip: BlockNumber, + ciphertext: &[u8], + associated_data: &[u8], + ) { + assert!(matches!( + decrypter + .decrypt_transaction_inputs(key_id, chain_tip, ciphertext, associated_data) + .await, + Err(TransactionInputDecryptionError::PrematureKey { .. }) + )); } - /// Different epochs must yield different keys under the same master secret, and the next key of - /// one epoch must equal the current key of the following epoch. - #[tokio::test] - async fn epochs_yield_distinct_but_consistent_keys() { - let decrypter = decrypter_from(&[7u8; 32]); - let keys_3 = decrypter.encryption_keys(3).await.unwrap(); - let keys_4 = decrypter.encryption_keys(4).await.unwrap(); + async fn assert_expired( + decrypter: &LocalX25519TransactionInputDecrypter, + key_id: &[u8], + chain_tip: BlockNumber, + ciphertext: &[u8], + associated_data: &[u8], + ) { + assert!(matches!( + decrypter + .decrypt_transaction_inputs(key_id, chain_tip, ciphertext, associated_data) + .await, + Err(TransactionInputDecryptionError::ExpiredKey { .. }) + )); + } - assert_ne!(keys_3.current.public_key, keys_4.current.public_key); - assert_ne!(keys_3.current.key_id, keys_4.current.key_id); + #[tokio::test] + async fn same_key_yields_same_public_material() { + let a = decrypter(7).encryption_key_schedule(BlockNumber::from(1)).await.unwrap(); + let b = decrypter(7).encryption_key_schedule(BlockNumber::from_epoch(4)).await.unwrap(); - let next_3 = keys_3.next.unwrap(); - assert_eq!(next_3.key, keys_4.current); - assert_eq!(next_3.rotation_block_num, BlockNumber::from_epoch(4).as_u32()); + assert_eq!(a, b); + assert_eq!(a.current_key.key_id.len(), 32); } - /// The current-key and next-key attestation commitments over the same key material must differ, - /// and the next-key commitment must bind the rotation block. - #[test] - fn attestation_commitment_binds_role_and_rotation_block() { - let genesis = Word::try_from([1u64, 2, 3, 4]).unwrap(); - let key = decrypter_from(&[7u8; 32]).key_info_for_epoch(3); - - let as_current = key.attestation_commitment(genesis); - let as_next = NextEncryptionKeyInfo { - key: key.clone(), - rotation_block_num: BlockNumber::from_epoch(4).as_u32(), - } - .attestation_commitment(genesis); - let as_next_other_block = NextEncryptionKeyInfo { - key, - rotation_block_num: BlockNumber::from_epoch(5).as_u32(), - } - .attestation_commitment(genesis); + #[tokio::test] + async fn different_keys_yield_different_provider_owned_ids() { + let a = decrypter(7).encryption_key_schedule(BlockNumber::GENESIS).await.unwrap(); + let b = decrypter(8).encryption_key_schedule(BlockNumber::GENESIS).await.unwrap(); - assert_ne!(as_current, as_next); - assert_ne!(as_next, as_next_other_block); + assert_ne!(a.current_key.public_key, b.current_key.public_key); + assert_ne!(a.current_key.key_id, b.current_key.key_id); } - /// At the final epoch no next epoch exists, so no next key can be announced. #[tokio::test] - async fn final_epoch_has_no_next_key() { - let keys = decrypter_from(&[7u8; 32]).encryption_keys(u16::MAX).await.unwrap(); - assert!(keys.next.is_none()); + async fn scheduled_key_activates_only_at_declared_epoch_boundary() { + let decrypter = scheduled_decrypter(); + let before = decrypter + .encryption_key_schedule(BlockNumber::from((1 << 16) - 1)) + .await + .unwrap(); + let at = decrypter.encryption_key_schedule(BlockNumber::from_epoch(1)).await.unwrap(); + + assert!(before.next_key.is_some()); + assert_eq!(before.next_key.unwrap().activation_block_num, BlockNumber::from_epoch(1)); + assert_eq!(at.current_key.key_id, decrypter.next.as_ref().unwrap().key.info.key_id); + assert_eq!(at.current_key_activation_block_num, BlockNumber::from_epoch(1)); + assert!(at.next_key.is_none()); + } + + #[test] + fn scheduled_rotation_rejects_non_boundary_activation() { + let result = decrypter(7).with_scheduled_rotation(key(8), BlockNumber::from((1 << 16) + 1)); + assert!(result.is_err()); } - /// A message sealed against an epoch's sealing key must decrypt to the original plaintext, and - /// decryption must reject a mismatched associated data or a mismatched key. #[tokio::test] - async fn seal_decrypt_roundtrip() { - let mut rng = rng(); - let decrypter = decrypter_from(&[7u8; 32]); - let epoch = 3; - let plaintext = b"transaction inputs"; + async fn decrypts_with_current_key_id() { + let decrypter = decrypter(7); + let schedule = decrypter.encryption_key_schedule(BlockNumber::from(42)).await.unwrap(); let associated_data = b"scheme|key_id|chain|tx"; - - let sealed = decrypter - .sealing_key_for_epoch(epoch) - .seal_bytes_with_associated_data(&mut rng, plaintext, associated_data) - .unwrap() - .to_bytes(); - let opened = decrypter - .decrypt_transaction_inputs(epoch, &sealed, associated_data) + let ciphertext = seal(&decrypter.sealing_key(), b"transaction inputs", associated_data); + + let plaintext = decrypter + .decrypt_transaction_inputs( + &schedule.current_key.key_id, + BlockNumber::from(42), + &ciphertext, + associated_data, + ) .await .unwrap(); - assert_eq!(opened.as_slice(), plaintext); + assert_eq!(plaintext, b"transaction inputs"); + } - // Mismatched associated data must fail authentication. - assert!( + #[tokio::test] + async fn enforces_premature_grace_expired_and_unknown_key_ids() { + let decrypter = scheduled_decrypter(); + let before = decrypter.encryption_key_schedule(BlockNumber::GENESIS).await.unwrap(); + let current_id = before.current_key.key_id; + let next_id = before.next_key.unwrap().key.key_id; + let associated_data = b"associated"; + let current_ciphertext = seal(&decrypter.sealing_key(), b"current", associated_data); + let next_ciphertext = + seal(&decrypter.next_sealing_key().unwrap(), b"next", associated_data); + + assert!(matches!( decrypter - .decrypt_transaction_inputs(epoch, &sealed, b"wrong associated data") + .decrypt_transaction_inputs( + &next_id, + BlockNumber::from((1 << 16) - 1), + &next_ciphertext, + associated_data, + ) + .await, + Err(TransactionInputDecryptionError::PrematureKey { .. }) + )); + + let activation = BlockNumber::from_epoch(1); + assert_eq!( + decrypter + .decrypt_transaction_inputs( + &next_id, + activation, + &next_ciphertext, + associated_data, + ) .await - .is_err() + .unwrap(), + b"next" ); - - // A different master secret must fail to decrypt. - let other = decrypter_from(&[8u8; 32]); - assert!(other.decrypt_transaction_inputs(epoch, &sealed, associated_data).await.is_err()); - - // Garbage ciphertext must fail to deserialize. - assert!( + assert_eq!( decrypter - .decrypt_transaction_inputs(epoch, b"not a sealed message", associated_data) + .decrypt_transaction_inputs( + ¤t_id, + activation, + ¤t_ciphertext, + associated_data, + ) .await - .is_err() + .unwrap(), + b"current" ); + + assert!(matches!( + decrypter + .decrypt_transaction_inputs( + ¤t_id, + BlockNumber::from_epoch(2), + ¤t_ciphertext, + associated_data, + ) + .await, + Err(TransactionInputDecryptionError::ExpiredKey { .. }) + )); + assert!(matches!( + decrypter + .decrypt_transaction_inputs( + b"not-a-provider-key", + activation, + &next_ciphertext, + associated_data, + ) + .await, + Err(TransactionInputDecryptionError::UnknownKey { .. }) + )); } - /// A message sealed during epoch `e` must remain decryptable during epoch `e + 1` (grace - /// window) but not during epoch `e + 2`. #[tokio::test] - async fn previous_epoch_key_grants_grace_window() { - let mut rng = rng(); - let decrypter = decrypter_from(&[7u8; 32]); - let associated_data = b"associated data"; - - let sealed = decrypter - .sealing_key_for_epoch(3) - .seal_bytes_with_associated_data(&mut rng, b"inputs", associated_data) - .unwrap() - .to_bytes(); + async fn two_manual_rotations_enforce_activation_and_grace() { + let decrypter = repeated_rotation_decrypter(); + let previous_id = decrypter.previous.as_ref().unwrap().key.info.key_id.clone(); + let current_id = decrypter.current.key.info.key_id.clone(); + let next_id = decrypter.next.as_ref().unwrap().key.info.key_id.clone(); + let associated_data = b"associated"; + let (previous_ciphertext, current_ciphertext, next_ciphertext) = + repeated_rotation_ciphertexts(&decrypter, associated_data); + + let before_first = BlockNumber::from((1 << 16) - 1); + let before_schedule = decrypter.encryption_key_schedule(before_first).await.unwrap(); + assert_eq!(before_schedule.current_key.key_id, previous_id); + assert_eq!(before_schedule.next_key.unwrap().key.key_id, current_id); + assert_premature( + &decrypter, + ¤t_id, + before_first, + ¤t_ciphertext, + associated_data, + ) + .await; + assert_premature(&decrypter, &next_id, before_first, &next_ciphertext, associated_data) + .await; + + let first_activation = BlockNumber::from_epoch(1); + let first_schedule = decrypter.encryption_key_schedule(first_activation).await.unwrap(); + assert_eq!(first_schedule.current_key.key_id, current_id); + assert_eq!(first_schedule.next_key.unwrap().key.key_id, next_id); + assert_decrypts( + &decrypter, + &previous_id, + first_activation, + &previous_ciphertext, + associated_data, + b"previous", + ) + .await; + + let second_activation = BlockNumber::from_epoch(2); + let second_schedule = decrypter.encryption_key_schedule(second_activation).await.unwrap(); + assert_eq!(second_schedule.current_key.key_id, next_id); + assert!(second_schedule.next_key.is_none()); + assert_decrypts( + &decrypter, + ¤t_id, + second_activation, + ¤t_ciphertext, + associated_data, + b"current", + ) + .await; + assert_expired( + &decrypter, + &previous_id, + second_activation, + &previous_ciphertext, + associated_data, + ) + .await; + assert_decrypts( + &decrypter, + &next_id, + second_activation, + &next_ciphertext, + associated_data, + b"next", + ) + .await; + + assert_expired( + &decrypter, + ¤t_id, + BlockNumber::from_epoch(3), + ¤t_ciphertext, + associated_data, + ) + .await; + assert!(matches!( + decrypter + .decrypt_transaction_inputs( + b"unknown", + second_activation, + &next_ciphertext, + associated_data, + ) + .await, + Err(TransactionInputDecryptionError::UnknownKey { .. }) + )); + } + + #[tokio::test] + async fn previous_key_can_be_dropped_after_grace_expiry() { + let decrypter = LocalX25519TransactionInputDecrypter::from_schedule( + None, + (key(8), BlockNumber::from_epoch(1)), + None, + ) + .unwrap(); - assert!(decrypter.decrypt_transaction_inputs(4, &sealed, associated_data).await.is_ok()); - assert!(decrypter.decrypt_transaction_inputs(5, &sealed, associated_data).await.is_err()); + assert!(decrypter.encryption_key_schedule(BlockNumber::from_epoch(1)).await.is_err()); + + let schedule = decrypter.encryption_key_schedule(BlockNumber::from_epoch(2)).await.unwrap(); + assert_eq!(schedule.current_key.key_id, decrypter.current.key.info.key_id); + } + + #[tokio::test] + async fn rejects_wrong_associated_data_and_malformed_ciphertext() { + let decrypter = decrypter(7); + let key_id = decrypter.current.key.info.key_id.clone(); + let ciphertext = seal(&decrypter.sealing_key(), b"inputs", b"correct"); + + assert!(matches!( + decrypter + .decrypt_transaction_inputs(&key_id, BlockNumber::GENESIS, &ciphertext, b"wrong",) + .await, + Err(TransactionInputDecryptionError::DecryptionFailed(_)) + )); + assert!(matches!( + decrypter + .decrypt_transaction_inputs( + &key_id, + BlockNumber::GENESIS, + b"not a sealed message", + b"correct", + ) + .await, + Err(TransactionInputDecryptionError::InvalidCiphertext(_)) + )); + } + + struct OperationOnlyProvider { + schedule: TransactionEncryptionKeySchedule, + } + + #[tonic::async_trait] + impl TransactionInputDecrypter for OperationOnlyProvider { + async fn encryption_key_schedule( + &self, + _chain_tip: BlockNumber, + ) -> anyhow::Result { + Ok(self.schedule.clone()) + } + + async fn decrypt_transaction_inputs( + &self, + _key_id: &[u8], + _chain_tip: BlockNumber, + ciphertext: &[u8], + _associated_data: &[u8], + ) -> Result, TransactionInputDecryptionError> { + Ok(ciphertext.to_vec()) + } + } + + #[tokio::test] + async fn provider_contract_does_not_require_secret_export() { + let schedule = decrypter(7).encryption_key_schedule(BlockNumber::GENESIS).await.unwrap(); + let provider = OperationOnlyProvider { schedule }; + + assert_eq!( + provider + .decrypt_transaction_inputs( + b"opaque", + BlockNumber::GENESIS, + b"plaintext from hardware", + b"associated", + ) + .await + .unwrap(), + b"plaintext from hardware" + ); } } diff --git a/crates/proto/src/domain/mod.rs b/crates/proto/src/domain/mod.rs index e04eff7947..be3ec81901 100644 --- a/crates/proto/src/domain/mod.rs +++ b/crates/proto/src/domain/mod.rs @@ -7,6 +7,7 @@ pub mod note; pub mod nullifier; pub mod proof_request; pub mod transaction; +pub mod transaction_encryption; // UTILITIES // ================================================================================================ diff --git a/crates/proto/src/domain/transaction_encryption.rs b/crates/proto/src/domain/transaction_encryption.rs new file mode 100644 index 0000000000..8b14a5d017 --- /dev/null +++ b/crates/proto/src/domain/transaction_encryption.rs @@ -0,0 +1,467 @@ +use miden_protocol::Word; +use miden_protocol::block::BlockNumber; +use miden_protocol::crypto::dsa::ecdsa_k256_keccak::{PublicKey, Signature}; +use miden_protocol::crypto::dsa::eddsa_25519_sha512::PublicKey as EncryptionPublicKey; +use miden_protocol::utils::serde::{Deserializable, Serializable}; + +use crate::generated as proto; + +/// Domain tag for signatures over complete transaction encryption key schedules. +pub const ATTESTATION_DOMAIN: &[u8] = b"MIDEN_TX_ENCRYPTION_KEY_SCHEDULE_ATTESTATION_V2"; + +/// Public metadata for one provider-owned transaction encryption key. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TransactionEncryptionKeyInfo { + /// Wire identifier of the encryption scheme. + pub scheme: u32, + /// Opaque identifier assigned by the provider. + pub key_id: Vec, + /// Serialized public key. + pub public_key: Vec, +} + +/// A key scheduled to become current at an epoch boundary. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NextTransactionEncryptionKey { + pub key: TransactionEncryptionKeyInfo, + pub activation_block_num: BlockNumber, +} + +/// The complete transaction encryption key schedule served by a validator. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TransactionEncryptionKeySchedule { + pub current_key: TransactionEncryptionKeyInfo, + pub current_key_activation_block_num: BlockNumber, + pub next_key: Option, +} + +impl TransactionEncryptionKeySchedule { + /// Computes the commitment signed by validators for this schedule in `attestation_epoch`. + pub fn attestation_commitment(&self, genesis_commitment: Word, attestation_epoch: u16) -> Word { + let mut payload = Vec::new(); + payload.extend_from_slice(ATTESTATION_DOMAIN); + payload.extend_from_slice(&genesis_commitment.to_bytes()); + payload.extend_from_slice(&u32::from(attestation_epoch).to_le_bytes()); + payload.extend_from_slice(&self.current_key_activation_block_num.as_u32().to_le_bytes()); + encode_key(&mut payload, &self.current_key); + + match &self.next_key { + None => payload.push(0), + Some(next) => { + payload.push(1); + payload.extend_from_slice(&next.activation_block_num.as_u32().to_le_bytes()); + encode_key(&mut payload, &next.key); + }, + } + + miden_protocol::Hasher::hash(&payload) + } + + /// Validates activation rules against a trusted chain tip. + pub fn validate_at( + &self, + trusted_chain_tip: BlockNumber, + ) -> Result<(), TransactionEncryptionKeyScheduleError> { + validate_epoch_boundary(self.current_key_activation_block_num, "current key activation")?; + if self.current_key_activation_block_num > trusted_chain_tip { + return Err(TransactionEncryptionKeyScheduleError::PrematureCurrentKey { + activation: self.current_key_activation_block_num, + trusted_chain_tip, + }); + } + + validate_key(&self.current_key, "current")?; + + if let Some(next) = &self.next_key { + validate_epoch_boundary(next.activation_block_num, "next key activation")?; + if next.activation_block_num <= trusted_chain_tip { + return Err(TransactionEncryptionKeyScheduleError::NextKeyAlreadyActive { + activation: next.activation_block_num, + trusted_chain_tip, + }); + } + if next.activation_block_num <= self.current_key_activation_block_num { + return Err(TransactionEncryptionKeyScheduleError::InvalidActivationOrder); + } + validate_key(&next.key, "next")?; + if next.key.key_id == self.current_key.key_id { + return Err(TransactionEncryptionKeyScheduleError::DuplicateKeyId); + } + } + + Ok(()) + } +} + +/// Trusted chain information required to verify a served key schedule. +pub struct TrustedChainState<'a> { + pub genesis_commitment: Word, + pub chain_tip: BlockNumber, + pub validator_keys: &'a [PublicKey], +} + +/// Parses and verifies a transaction encryption key schedule against trusted chain state. +pub fn verify_transaction_encryption_key_schedule( + response: &proto::transaction::TransactionEncryptionKeyResponse, + trusted: &TrustedChainState<'_>, +) -> Result { + let attestation_epoch = u16::try_from(response.attestation_epoch) + .map_err(|_| TransactionEncryptionKeyScheduleError::InvalidAttestationEpoch)?; + let trusted_epoch = trusted.chain_tip.block_epoch(); + if attestation_epoch != trusted_epoch { + return Err(TransactionEncryptionKeyScheduleError::StaleAttestation { + attestation_epoch, + trusted_epoch, + }); + } + + let current_key = response + .current_key + .as_ref() + .ok_or(TransactionEncryptionKeyScheduleError::MissingCurrentKey) + .and_then(|key| decode_key(key, "current"))?; + let next_key = response + .next_key + .as_ref() + .map(|next| { + let key = next + .key + .as_ref() + .ok_or(TransactionEncryptionKeyScheduleError::MissingNextKey) + .and_then(|key| decode_key(key, "next"))?; + Ok(NextTransactionEncryptionKey { + key, + activation_block_num: BlockNumber::from(next.activation_block_num), + }) + }) + .transpose()?; + let schedule = TransactionEncryptionKeySchedule { + current_key, + current_key_activation_block_num: BlockNumber::from( + response.current_key_activation_block_num, + ), + next_key, + }; + schedule.validate_at(trusted.chain_tip)?; + + let commitment = schedule.attestation_commitment(trusted.genesis_commitment, attestation_epoch); + let verified = response.attestations.iter().any(|attestation| { + let Ok(validator_key) = PublicKey::read_from_bytes(&attestation.validator_public_key) + else { + return false; + }; + if !trusted.validator_keys.contains(&validator_key) { + return false; + } + let Ok(signature) = Signature::read_from_bytes(&attestation.signature) else { + return false; + }; + signature.verify(commitment, &validator_key) + }); + if !verified { + return Err(TransactionEncryptionKeyScheduleError::NoTrustedAttestation); + } + + Ok(schedule) +} + +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum TransactionEncryptionKeyScheduleError { + #[error("the response is missing its current key")] + MissingCurrentKey, + #[error("the response contains a next-key wrapper without a key")] + MissingNextKey, + #[error("the {0} key uses an unsupported encryption scheme")] + UnsupportedScheme(&'static str), + #[error("the {0} key id is empty")] + EmptyKeyId(&'static str), + #[error("the {0} public key is invalid")] + InvalidPublicKey(&'static str), + #[error("the attestation epoch does not fit in the chain epoch type")] + InvalidAttestationEpoch, + #[error( + "schedule attestation epoch {attestation_epoch} does not match trusted epoch {trusted_epoch}" + )] + StaleAttestation { + attestation_epoch: u16, + trusted_epoch: u16, + }, + #[error("{name} block {block_num} is not an epoch boundary")] + NotEpochBoundary { + name: &'static str, + block_num: BlockNumber, + }, + #[error("current key activates at {activation}, after trusted chain tip {trusted_chain_tip}")] + PrematureCurrentKey { + activation: BlockNumber, + trusted_chain_tip: BlockNumber, + }, + #[error( + "next key activated at {activation}, no later than trusted chain tip {trusted_chain_tip}" + )] + NextKeyAlreadyActive { + activation: BlockNumber, + trusted_chain_tip: BlockNumber, + }, + #[error("next key activation must be after current key activation")] + InvalidActivationOrder, + #[error("current and next keys must have distinct provider-owned ids")] + DuplicateKeyId, + #[error("the response has no valid attestation from a trusted validator")] + NoTrustedAttestation, +} + +fn encode_key(payload: &mut Vec, key: &TransactionEncryptionKeyInfo) { + payload.extend_from_slice(&key.scheme.to_le_bytes()); + extend_with_length_prefixed(payload, &key.key_id, "key id"); + extend_with_length_prefixed(payload, &key.public_key, "public key"); +} + +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); +} + +fn validate_epoch_boundary( + block_num: BlockNumber, + name: &'static str, +) -> Result<(), TransactionEncryptionKeyScheduleError> { + if BlockNumber::from_epoch(block_num.block_epoch()) != block_num { + return Err(TransactionEncryptionKeyScheduleError::NotEpochBoundary { name, block_num }); + } + Ok(()) +} + +fn validate_key( + key: &TransactionEncryptionKeyInfo, + role: &'static str, +) -> Result<(), TransactionEncryptionKeyScheduleError> { + if key.scheme != u32::from(proto::transaction::IesScheme::X25519Xchacha20Poly1305 as u8) { + return Err(TransactionEncryptionKeyScheduleError::UnsupportedScheme(role)); + } + if key.key_id.is_empty() { + return Err(TransactionEncryptionKeyScheduleError::EmptyKeyId(role)); + } + EncryptionPublicKey::read_from_bytes(&key.public_key) + .map_err(|_| TransactionEncryptionKeyScheduleError::InvalidPublicKey(role))?; + Ok(()) +} + +fn decode_key( + key: &proto::transaction::TransactionEncryptionKey, + role: &'static str, +) -> Result { + let scheme = u32::try_from(key.scheme) + .map_err(|_| TransactionEncryptionKeyScheduleError::UnsupportedScheme(role))?; + let key = TransactionEncryptionKeyInfo { + scheme, + key_id: key.key_id.clone(), + public_key: key.public_key.clone(), + }; + validate_key(&key, role)?; + Ok(key) +} + +#[cfg(test)] +mod tests { + use miden_protocol::crypto::dsa::ecdsa_k256_keccak::SigningKey; + use miden_protocol::crypto::dsa::eddsa_25519_sha512::KeyExchangeKey; + + use super::*; + + const CURRENT_ACTIVATION: u32 = 0; + const NEXT_ACTIVATION: u32 = 1 << 16; + + fn key(seed: u8) -> TransactionEncryptionKeyInfo { + let public_key = KeyExchangeKey::read_from_bytes(&[seed; 32]).unwrap().public_key(); + TransactionEncryptionKeyInfo { + scheme: u32::from(proto::transaction::IesScheme::X25519Xchacha20Poly1305 as u8), + key_id: public_key.to_commitment().to_bytes(), + public_key: public_key.to_bytes(), + } + } + + fn signed_response( + schedule: &TransactionEncryptionKeySchedule, + attestation_epoch: u16, + genesis: Word, + signer: &SigningKey, + ) -> proto::transaction::TransactionEncryptionKeyResponse { + let encode = + |key: &TransactionEncryptionKeyInfo| proto::transaction::TransactionEncryptionKey { + scheme: i32::try_from(key.scheme).unwrap(), + key_id: key.key_id.clone(), + public_key: key.public_key.clone(), + }; + let signature = signer + .sign(schedule.attestation_commitment(genesis, attestation_epoch)) + .to_bytes(); + proto::transaction::TransactionEncryptionKeyResponse { + current_key: Some(encode(&schedule.current_key)), + next_key: schedule.next_key.as_ref().map(|next| { + proto::transaction::NextTransactionEncryptionKey { + key: Some(encode(&next.key)), + activation_block_num: next.activation_block_num.as_u32(), + } + }), + current_key_activation_block_num: schedule.current_key_activation_block_num.as_u32(), + attestation_epoch: u32::from(attestation_epoch), + attestations: vec![proto::transaction::ValidatorKeyAttestation { + validator_public_key: signer.public_key().to_bytes(), + signature, + }], + } + } + + fn schedule(next: bool) -> TransactionEncryptionKeySchedule { + TransactionEncryptionKeySchedule { + current_key: key(7), + current_key_activation_block_num: BlockNumber::from(CURRENT_ACTIVATION), + next_key: next.then(|| NextTransactionEncryptionKey { + key: key(8), + activation_block_num: BlockNumber::from(NEXT_ACTIVATION), + }), + } + } + + #[test] + fn verifies_schedule_without_rotation() { + let signer = SigningKey::read_from_bytes(&[9; 32]).unwrap(); + let genesis = Word::from([1_u32, 2, 3, 4]); + let schedule = schedule(false); + let response = signed_response(&schedule, 0, genesis, &signer); + let trusted = TrustedChainState { + genesis_commitment: genesis, + chain_tip: BlockNumber::from(42), + validator_keys: &[signer.public_key()], + }; + + assert_eq!( + verify_transaction_encryption_key_schedule(&response, &trusted).unwrap(), + schedule + ); + } + + #[test] + fn verifies_schedule_with_next_key() { + let signer = SigningKey::read_from_bytes(&[9; 32]).unwrap(); + let genesis = Word::from([1_u32, 2, 3, 4]); + let schedule = schedule(true); + let response = signed_response(&schedule, 0, genesis, &signer); + let trusted = TrustedChainState { + genesis_commitment: genesis, + chain_tip: BlockNumber::from(42), + validator_keys: &[signer.public_key()], + }; + + assert_eq!( + verify_transaction_encryption_key_schedule(&response, &trusted).unwrap(), + schedule + ); + } + + #[test] + fn one_signature_covers_optional_next_presence() { + let signer = SigningKey::read_from_bytes(&[9; 32]).unwrap(); + let genesis = Word::from([1_u32, 2, 3, 4]); + let trusted = TrustedChainState { + genesis_commitment: genesis, + chain_tip: BlockNumber::from(42), + validator_keys: &[signer.public_key()], + }; + + let mut stripped = signed_response(&schedule(true), 0, genesis, &signer); + stripped.next_key = None; + assert_eq!( + verify_transaction_encryption_key_schedule(&stripped, &trusted), + Err(TransactionEncryptionKeyScheduleError::NoTrustedAttestation) + ); + + let mut injected = signed_response(&schedule(false), 0, genesis, &signer); + injected.next_key = signed_response(&schedule(true), 0, genesis, &signer).next_key; + assert_eq!( + verify_transaction_encryption_key_schedule(&injected, &trusted), + Err(TransactionEncryptionKeyScheduleError::NoTrustedAttestation) + ); + } + + #[test] + fn rejects_stale_schedule_replay() { + let signer = SigningKey::read_from_bytes(&[9; 32]).unwrap(); + let genesis = Word::from([1_u32, 2, 3, 4]); + let response = signed_response(&schedule(false), 0, genesis, &signer); + let trusted = TrustedChainState { + genesis_commitment: genesis, + chain_tip: BlockNumber::from_epoch(1), + validator_keys: &[signer.public_key()], + }; + + assert_eq!( + verify_transaction_encryption_key_schedule(&response, &trusted), + Err(TransactionEncryptionKeyScheduleError::StaleAttestation { + attestation_epoch: 0, + trusted_epoch: 1, + }) + ); + } + + #[test] + fn rejects_premature_current_key() { + let signer = SigningKey::read_from_bytes(&[9; 32]).unwrap(); + let genesis = Word::from([1_u32, 2, 3, 4]); + let mut schedule = schedule(false); + schedule.current_key_activation_block_num = BlockNumber::from_epoch(1); + let response = signed_response(&schedule, 0, genesis, &signer); + let trusted = TrustedChainState { + genesis_commitment: genesis, + chain_tip: BlockNumber::from(42), + validator_keys: &[signer.public_key()], + }; + + assert!(matches!( + verify_transaction_encryption_key_schedule(&response, &trusted), + Err(TransactionEncryptionKeyScheduleError::PrematureCurrentKey { .. }) + )); + } + + #[test] + fn rejects_non_boundary_activation() { + let signer = SigningKey::read_from_bytes(&[9; 32]).unwrap(); + let genesis = Word::from([1_u32, 2, 3, 4]); + let mut schedule = schedule(true); + schedule.next_key.as_mut().unwrap().activation_block_num = + BlockNumber::from(NEXT_ACTIVATION + 1); + let response = signed_response(&schedule, 0, genesis, &signer); + let trusted = TrustedChainState { + genesis_commitment: genesis, + chain_tip: BlockNumber::from(42), + validator_keys: &[signer.public_key()], + }; + + assert!(matches!( + verify_transaction_encryption_key_schedule(&response, &trusted), + Err(TransactionEncryptionKeyScheduleError::NotEpochBoundary { .. }) + )); + } + + #[test] + fn rejects_untrusted_validator() { + let signer = SigningKey::read_from_bytes(&[9; 32]).unwrap(); + let other = SigningKey::read_from_bytes(&[10; 32]).unwrap(); + let genesis = Word::from([1_u32, 2, 3, 4]); + let response = signed_response(&schedule(false), 0, genesis, &signer); + let trusted = TrustedChainState { + genesis_commitment: genesis, + chain_tip: BlockNumber::from(42), + validator_keys: &[other.public_key()], + }; + + assert_eq!( + verify_transaction_encryption_key_schedule(&response, &trusted), + Err(TransactionEncryptionKeyScheduleError::NoTrustedAttestation) + ); + } +} diff --git a/crates/rpc/src/tests.rs b/crates/rpc/src/tests.rs index 4d3d24bea5..ff95dc3cd8 100644 --- a/crates/rpc/src/tests.rs +++ b/crates/rpc/src/tests.rs @@ -848,23 +848,21 @@ fn test_encryption_key() -> proto::transaction::TransactionEncryptionKeyResponse 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 { 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, + activation_block_num: 1 << 16, }), + current_key_activation_block_num: 0, + attestation_epoch: 0, + attestations: vec![proto::transaction::ValidatorKeyAttestation { + validator_public_key: vec![8; 33], + signature: vec![9; 65], + }], } } diff --git a/crates/store/src/state/loader.rs b/crates/store/src/state/loader.rs index 49df6c4206..eb42380dea 100644 --- a/crates/store/src/state/loader.rs +++ b/crates/store/src/state/loader.rs @@ -35,11 +35,13 @@ use miden_protocol::{Felt, Word}; #[cfg(feature = "rocksdb")] use tracing::info; +use crate::COMPONENT; +#[cfg(feature = "rocksdb")] +use crate::LOG_TARGET; use crate::account_state_forest::AccountStateForest; use crate::db::Db; use crate::db::models::queries::BlockHeaderCommitment; use crate::errors::{DatabaseError, StateInitializationError}; -use crate::{COMPONENT, LOG_TARGET}; // CONSTANTS // ================================================================================================ diff --git a/docs/external/src/network-operator/validator.md b/docs/external/src/network-operator/validator.md index 638420818d..d48322a78f 100644 --- a/docs/external/src/network-operator/validator.md +++ b/docs/external/src/network-operator/validator.md @@ -38,21 +38,27 @@ 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 master secret, configured with -`--encryption-key.hex` or `MIDEN_VALIDATOR_ENCRYPTION_KEY`. The actual encryption keys are derived from this secret per -epoch and rotate automatically at each epoch boundary, with no operator action required. Unlike the signing key, this -value must be identical across every validator in the set. The validator logs a warning at startup if the insecure -development default is in use. - -Each epoch's derived secret key is archived in the validator's database, preserving the key material needed to recover -past submissions should the master secret ever be replaced (the live decrypt path always re-derives from the master -secret). The archive holds raw key material, so the validator's data directory must be protected accordingly. - -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. +In addition to its signing key, every validator holds the shared transaction encryption key, configured with +`--encryption-key.hex` or `MIDEN_VALIDATOR_ENCRYPTION_KEY`. Unlike the signing key, this value must be identical across +every validator in the set. The validator does not derive replacement keys or rotate it automatically. The validator +logs a warning at startup if the insecure development default is in use. + +The validator accepts an optional previous key for grace decryption and an optional next key for a planned rotation. +Each key has an activation block, which must be an epoch boundary. Configure these with `--encryption-key.previous.*`, +`--encryption-key.activation-block`, and `--encryption-key.next.*`. Hex and KMS ciphertext sources are supported for all +three keys. + +All validators must restart with the same key schedule whenever the configured keys change. Keys then activate at their +epoch boundaries without another restart. For example, a restart that activates key B and announces key C uses A as the +previous key, B as the current key, and C as the next key. The provider accepts A through B's activation epoch, then +marks it expired. After that epoch, a restart can drop A. When C activates, move B into the previous slot. + +The validator logs a warning when any key is loaded from plain hex. Production deployments should instead wrap each key +with a symmetric AWS KMS key (`aws kms encrypt`) and pass the resulting base64 ciphertext blob unchanged via +`--encryption-key.kms-ciphertext` or `MIDEN_VALIDATOR_ENCRYPTION_KEY_KMS_CIPHERTEXT`. The validator recovers the key +material at startup with `kms:Decrypt`, so its AWS identity needs that permission on the wrapping key. Note that, unlike +KMS-backed signing, the decrypted encryption key is held in validator memory: AWS KMS cannot perform X25519 key +agreement itself, so envelope encryption is the supported provisioning path. Other providers may keep the secret outside +the validator process because the provider contract requires only public schedule metadata and a decrypt operation. + +Use `miden-validator start --help` for the complete option list. diff --git a/docs/external/src/rpc/public-api.md b/docs/external/src/rpc/public-api.md index 3cfe9eb618..13e46fd94a 100644 --- a/docs/external/src/rpc/public-api.md +++ b/docs/external/src/rpc/public-api.md @@ -38,16 +38,21 @@ grpcurl rpc.testnet.miden.io:443 describe rpc.Api | `SubmitProvenTx` | Submits one proven transaction and returns the node's current block height. | | `SubmitProvenTxBatch` | Submits an atomic batch of proven transactions and returns the node's current block height. | -The keys returned by `GetTransactionEncryptionKey` are shared across the whole validator set, while each attestation is -specific to one validator (currently each key carries a single attestation). The encryption key rotates every epoch -(`2^16` blocks): the response carries the key currently in effect and the key that replaces it at the next epoch -boundary, together with the rotation block number. 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 `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: a replayed older -signed key still verifies until a chain or epoch rule for freshness exists, though the rotation block bound into -next-key attestations lets clients detect stale keys once the chain has passed that block. +The response carries the current key, its activation block, and an optional manually scheduled next key. Activations +must be epoch boundaries. One validator signature covers the complete schedule, including whether the next key is +absent, so an untrusted RPC cannot remove a scheduled key without invalidating the signature. + +Clients should use `miden_node_proto::domain::transaction_encryption` to verify the response against a genesis +commitment, trusted chain tip, and validator signing keys. The signed attestation epoch must match the trusted tip's +epoch, which rejects responses replayed from an earlier epoch. The verifier also rejects premature current keys, +already-active next keys, and activations that are not epoch boundaries. Key IDs are opaque provider-owned bytes and +must be sent back with encrypted submissions so the provider can select the intended key. + +Providers keep a schedule unchanged within an epoch. Preventing a validator from signing two different schedules in one +epoch requires this operational rule until the schedule has its own on-chain commitment. + +This scheme does not hide transaction inputs from holders of the shared encryption secret and provides no forward +secrecy. Write requests must identify the target network with the `genesis` parameter in the `Accept` header: diff --git a/docs/internal/src/validator.md b/docs/internal/src/validator.md index 8d7b624202..cc059c542d 100644 --- a/docs/internal/src/validator.md +++ b/docs/internal/src/validator.md @@ -29,35 +29,30 @@ 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 master secret for transaction encryption. From it, the validator derives one encryption -keypair per epoch (an Ed25519 key that miden-crypto uses for X25519 key agreement in its IES -scheme): the key seed is the blake3 hash of a domain tag, the master secret, and the epoch -number. Clients will use the epoch's key to encrypt the private transaction inputs they submit, -so that any validator in the set can decrypt them. - -The encryption key rotates at every epoch boundary (every `2^16` blocks). Since the derivation -is deterministic, all validators transition to the same new key without coordination. A -background task follows the validator's committed chain tip and, after each boundary, derives -the new epoch's keys and re-signs their attestations off the request path. Submissions sealed -against the previous epoch's key remain decryptable for one further epoch as a grace window. - -Each epoch's secret key is archived in the validator's database, at startup (backfilling any -epochs missed while offline) and at every rotation, always including the announced next epoch's -key since clients near a boundary may already seal against it. The archive preserves the key -material needed to recover past submissions should the shared master secret ever be replaced; -the live decrypt path always re-derives from the master secret. Decrypter implementations that -cannot export secret key bytes (e.g. a future TEE-held key) skip this archival and are -responsible for their own. - -The `GetTransactionEncryptionKey` endpoint returns the current key and the key that replaces it -at the next epoch boundary, each carrying an IES scheme identifier, an opaque key ID, and a list -of validator attestations, currently holding one signature from this validator's own signing key -over an attestation commitment (the `ValidatorKeyAttestation` proto message documents the exact -payload). The commitment carries a domain tag that separates attestations from block header -signatures, the genesis commitment so an attestation cannot replay across networks, and a role -suffix that separates current-key from next-key attestations and binds the next key's rotation -block. The signature proves to clients that a chain-recognized validator vouches for the key, so -the key can be served through an untrusted RPC. +transaction encryption provider. The provider owns its opaque key IDs and secret storage. It +stores an optional previous key, a current key, and an optional manually selected next key. It +exposes only the current and next public metadata, and it decrypts using the key ID supplied by +the caller. It does not expose raw secret bytes. + +A scheduled key may activate only at an epoch boundary. Before that boundary its ID is premature. +At the boundary it becomes current and the prior current key remains decrypt-only through the +activation epoch. The old ID is expired from the following epoch boundary onward. The provider, +not the validator service, enforces these rules. The validator does not derive keys or choose an +automatic rotation policy. Providers keep the schedule fixed within each epoch. Operators publish +a manually selected next key only at an epoch boundary and restart all validators with the same +previous, current, and next state. + +`GetTransactionEncryptionKey` returns the current key and optional next key as one schedule. A +single validator signature binds the complete schedule, including both activation blocks and the +presence or absence of the next key. It also binds the genesis commitment and an attestation +epoch. The validator lazily refreshes this attestation at most once per epoch without changing +the provider schedule. + +The canonical typed verifier lives in +`miden_node_proto::domain::transaction_encryption`. It checks the signature against +chain-recognized validator keys, requires the attestation epoch to match the trusted chain tip, +and enforces activation boundaries. This lets node-owned clients reject cross-network, stale, +premature, and structurally altered schedules served through an untrusted RPC. This scheme does not protect the inputs from parties holding the shared secret and has no forward secrecy. It is the first phase of the transaction input encryption design: later phases move the diff --git a/proto/proto/internal/validator.proto b/proto/proto/internal/validator.proto index e3ff2712c1..d2acab9f51 100644 --- a/proto/proto/internal/validator.proto +++ b/proto/proto/internal/validator.proto @@ -31,12 +31,11 @@ service Api { // Returns the public key that clients use to encrypt the private inputs of the transactions // they submit, attested by this validator's own signing key. // - // The encryption keys are derived per epoch from a secret shared across the whole validator - // set, so the returned keys are identical regardless of which validator serves the request. - // The attestations carried in the response are specific to this validator. + // The provider-owned keys are shared across the validator set, so the returned schedule is + // identical regardless of which validator serves the request. The schedule attestation is + // specific to this validator and refreshed once per epoch for replay protection. // - // The encryption key rotates every epoch. The response carries the key currently in effect - // and, when a next epoch exists, the key that replaces it at the next epoch boundary. + // Rotations are scheduled manually and may activate only at epoch boundaries. rpc GetTransactionEncryptionKey(google.protobuf.Empty) returns (transaction.TransactionEncryptionKeyResponse) {} } diff --git a/proto/proto/rpc.proto b/proto/proto/rpc.proto index 9103e3cfff..e4df491618 100644 --- a/proto/proto/rpc.proto +++ b/proto/proto/rpc.proto @@ -46,14 +46,12 @@ service Api { // Returns the public key that clients use to encrypt the private inputs of the transactions // they submit, so that validators can decrypt and validate them. // - // Every validator in the set derives the same per-epoch encryption keys from a shared - // secret, so the returned keys are identical regardless of which validator attests them. - // Each key carries a list of validator attestations, currently containing a single one. - // Since all validators vouch for the same keys, an attestation verifiable against any - // chain-recognized validator signing key is sufficient. + // Every validator in the set uses the same provider-owned key schedule. The complete current + // and optional-next schedule carries validator attestations, currently containing a single + // one. An attestation verifiable against any chain-recognized validator signing key is + // sufficient. // - // The encryption key rotates every epoch. The response carries the key currently in effect - // and, when a next epoch exists, the key that replaces it at the next epoch boundary. + // Rotations are scheduled manually and may activate only at epoch boundaries. rpc GetTransactionEncryptionKey(google.protobuf.Empty) returns (transaction.TransactionEncryptionKeyResponse) {} // Submits proven transaction to the Miden network. Returns the node's current block height. diff --git a/proto/proto/types/transaction.proto b/proto/proto/types/transaction.proto index 474b16e447..1a32d31927 100644 --- a/proto/proto/types/transaction.proto +++ b/proto/proto/types/transaction.proto @@ -71,22 +71,17 @@ enum IesScheme { IES_SCHEME_X25519_XCHACHA20_POLY1305 = 1; } -// A single validator's attestation of the transaction encryption key. +// A single validator's attestation of a complete transaction encryption key schedule. message ValidatorKeyAttestation { // Public key of the attesting validator, encoded using the [miden_serde_utils::Serializable] // implementation for [miden_protocol::crypto::dsa::ecdsa_k256_keccak::PublicKey]. Must match a // 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 || role_suffix`, 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. - // `role_suffix` is a single `0` byte when the key is attested as the current key, or a `1` - // byte followed by `rotation_block_num` (4 bytes little-endian) 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`. + // The validator's signature over the complete response schedule. The commitment binds the + // genesis commitment, attestation epoch, current key and activation block, and the presence or + // absence and contents of the optional next key. The canonical construction and verifier live + // in `miden_node_proto::domain::transaction_encryption`. bytes signature = 2; } @@ -111,28 +106,17 @@ message TransactionEncryptionKey { // key that miden-crypto converts internally for X25519 key agreement). bytes public_key = 3; - // 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; - - // 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 4, 5; + reserved "attestations", "next_key"; } // The next transaction encryption key, announced ahead of a scheduled key rotation. message NextTransactionEncryptionKey { - // The key that replaces the current one at the rotation block. Its attestations sign the - // next-key role suffix (`1 || rotation_block_num`), so a scheduled rotation cannot be altered - // or forged from a current-key attestation. + // The key that replaces the current one at the activation block. TransactionEncryptionKey key = 1; - // Block number at which the next key replaces the current one. Always the first block of the - // epoch following the current key's epoch: keys rotate every epoch (`2^16` blocks). - fixed32 rotation_block_num = 2; + // Block number at which the next key replaces the current one. Must be an epoch boundary. + fixed32 activation_block_num = 2; } // Response to a transaction encryption key request: the current key and the key scheduled to @@ -141,9 +125,22 @@ message TransactionEncryptionKeyResponse { // The encryption key currently in effect. TransactionEncryptionKey current_key = 1; - // The key that replaces `current_key` at `rotation_block_num`. Encryption keys rotate every - // epoch, so this is populated except in the degenerate case where no next epoch exists. + // The key that replaces `current_key` at its activation block when a manual rotation has been + // scheduled. optional NextTransactionEncryptionKey next_key = 2; + + // Epoch boundary at which the current key became active. + fixed32 current_key_activation_block_num = 3; + + // Epoch in which this schedule was attested. A verifier must compare this with a trusted chain + // tip so a schedule from an earlier epoch cannot be replayed. + fixed32 attestation_epoch = 4; + + // Validator attestations over this complete schedule. + // + // Currently contains a single attestation from the validator that served the request. + // Collecting attestations from the whole validator set requires validator intercommunication. + repeated ValidatorKeyAttestation attestations = 5; } // Represents a transaction ID.