Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
## Unreleased

- [BREAKING] Updated `miden-protocol` dependencies to use the `next` branch (v0.16). Block and transaction account updates now use the absolute `AccountPatch` representation instead of the relative `AccountDelta`, and the `miden-tx-batch-prover` crate was renamed to `miden-tx-batch` ([#2282](https://github.com/0xMiden/node/pull/2282)).
- Added the `GetTransactionEncryptionKey` endpoint to the validator and RPC APIs, returning the shared transaction encryption key attested by the serving validator's signing key. The shared secret is configured on the validator via `--encryption-key.hex` / `MIDEN_VALIDATOR_ENCRYPTION_KEY` and must be identical across the validator set ([#2342](https://github.com/0xMiden/node/pull/2342)).
- [BREAKING] Renamed the validator signing key options: `--key.hex` / `MIDEN_VALIDATOR_KEY` is now `--signing-key.hex` / `MIDEN_VALIDATOR_SIGNING_KEY`, and `--key.kms-id` / `MIDEN_VALIDATOR_KEY_KMS_ID` is now `--signing-key.kms-id` / `MIDEN_VALIDATOR_SIGNING_KEY_KMS_ID` ([#2342](https://github.com/0xMiden/node/pull/2342)).

## v0.15.0 (2026-06-10)

Expand Down
159 changes: 150 additions & 9 deletions bin/validator/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ const ENV_SIGNING_KEY: &str = "MIDEN_VALIDATOR_SIGNING_KEY";
const ENV_SIGNING_KEY_KMS_ID: &str = "MIDEN_VALIDATOR_SIGNING_KEY_KMS_ID";
const ENV_ENCRYPTION_KEY: &str = "MIDEN_VALIDATOR_ENCRYPTION_KEY";
const ENV_ENCRYPTION_KEY_KMS_CIPHERTEXT: &str = "MIDEN_VALIDATOR_ENCRYPTION_KEY_KMS_CIPHERTEXT";
const ENV_NEXT_ENCRYPTION_KEY: &str = "MIDEN_VALIDATOR_NEXT_ENCRYPTION_KEY";
const ENV_NEXT_ENCRYPTION_KEY_ROTATION_BLOCK: &str =
"MIDEN_VALIDATOR_NEXT_ENCRYPTION_KEY_ROTATION_BLOCK";
const ENV_GENESIS_CONFIG_FILE: &str = "MIDEN_VALIDATOR_GENESIS_CONFIG_FILE";
const ENV_SQLITE_CONNECTION_POOL_SIZE: &str = "MIDEN_VALIDATOR_SQLITE_CONNECTION_POOL_SIZE";

Expand Down Expand Up @@ -164,6 +167,33 @@ pub enum ValidatorCommand {
group = "encryption_key_source"
)]
encryption_key_kms_ciphertext: Option<String>,

/// Hex-encoded shared secret of the next transaction encryption key, scheduling a key
/// rotation at the block given by `encryption-key.next.rotation-block`.
///
/// Like the current key, this value and the rotation block must be identical across every
/// validator in the set, and every validator must be reconfigured before the rotation
/// block is reached. Must differ from the current encryption key.
///
/// Requires `encryption-key.next.rotation-block`.
#[arg(
long = "encryption-key.next.hex",
env = ENV_NEXT_ENCRYPTION_KEY,
value_name = "VALIDATOR_NEXT_ENCRYPTION_KEY",
requires = "encryption_key_rotation_block"
)]
encryption_key_next: Option<String>,

/// Block number at which the next transaction encryption key replaces the current one.
///
/// Requires `encryption-key.next.hex`.
#[arg(
long = "encryption-key.next.rotation-block",
env = ENV_NEXT_ENCRYPTION_KEY_ROTATION_BLOCK,
value_name = "ROTATION_BLOCK_NUM",
requires = "encryption_key_next"
)]
encryption_key_rotation_block: Option<u32>,
},
}

Expand Down Expand Up @@ -204,18 +234,21 @@ impl ValidatorCommand {
sqlite_connection_pool_size,
encryption_key,
encryption_key_kms_ciphertext,
encryption_key_next,
encryption_key_rotation_block,
..
} => {
let address = listen;

let encryption_key_bytes = if let Some(ciphertext) = encryption_key_kms_ciphertext {
let encryption_key_hex = if let Some(ciphertext) = encryption_key_kms_ciphertext {
let ciphertext =
base64::engine::general_purpose::STANDARD
.decode(ciphertext)
.context("failed to decode the encryption key KMS ciphertext base64")?;
miden_validator::decrypt_key_material(ciphertext)
let encryption_key_bytes = miden_validator::decrypt_key_material(ciphertext)
.await
.context("failed to decrypt the encryption key with KMS")?
.context("failed to decrypt the encryption key with KMS")?;
hex::encode(encryption_key_bytes)
} else {
// Unlike the signing key, whose insecure default is caught at startup against
// the chain's committed validator key, nothing cross-checks the encryption key.
Expand All @@ -229,13 +262,13 @@ impl ValidatorCommand {
);
}

hex::decode(encryption_key)
.context("failed to decode the encryption key hex")?
encryption_key
};
let encryption_key = KeyExchangeKey::read_from_bytes(&encryption_key_bytes)
.context("failed to construct the encryption key")?;
let decrypter: Arc<dyn TransactionInputDecrypter> =
Arc::new(LocalX25519TransactionInputDecrypter::new(encryption_key));
let decrypter: Arc<dyn TransactionInputDecrypter> = Arc::new(build_decrypter(
&encryption_key_hex,
encryption_key_next.as_deref(),
encryption_key_rotation_block,
)?);

let signer = if let Some(kms_key_id) = signing_key_kms_id {
ValidatorSigner::new_kms(kms_key_id).await?
Expand Down Expand Up @@ -266,6 +299,42 @@ impl ValidatorCommand {
}
}

// TRANSACTION INPUT DECRYPTER CONSTRUCTION
// ================================================================================================

/// Builds the transaction input decrypter from the hex-encoded shared secret and, when a rotation
/// is scheduled, the hex-encoded next shared secret and its rotation block.
fn build_decrypter(
encryption_key_hex: &str,
next_key_hex: Option<&str>,
rotation_block: Option<u32>,
) -> anyhow::Result<LocalX25519TransactionInputDecrypter> {
let encryption_key_bytes =
hex::decode(encryption_key_hex).context("failed to decode the encryption key hex")?;
let encryption_key = KeyExchangeKey::read_from_bytes(&encryption_key_bytes)
.context("failed to construct the encryption key")?;

let mut decrypter = LocalX25519TransactionInputDecrypter::new(encryption_key);
if let Some(next_key_hex) = next_key_hex {
let rotation_block =
rotation_block.context("encryption-key.next.hex requires a rotation block")?;
let next_key_bytes =
hex::decode(next_key_hex).context("failed to decode the next encryption key hex")?;
if next_key_bytes == encryption_key_bytes {
anyhow::bail!("the next encryption key must differ from the current encryption key");
}
let next_key = KeyExchangeKey::read_from_bytes(&next_key_bytes)
.context("failed to construct the next encryption key")?;
decrypter = decrypter.with_next_key(next_key, rotation_block);
tracing::info!(
target: LOG_TARGET,
rotation_block,
"Transaction encryption key rotation scheduled"
);
}
Ok(decrypter)
}

// VALIDATOR SIGNING KEY
// ================================================================================================

Expand Down Expand Up @@ -367,4 +436,76 @@ mod tests {
};
assert_eq!(error.kind(), clap::error::ErrorKind::ArgumentConflict);
}

const NEXT_KEY_HEX: &str = "0303030303030303030303030303030303030303030303030303030303030303";

/// The minimal `start` argument list the rotation flags are appended to.
fn start_args() -> Vec<&'static str> {
vec![
"miden-validator",
"start",
"--listen",
"127.0.0.1:0",
"--data-directory",
"/tmp/validator-data",
]
}

/// A rotation is only accepted as a complete pair: the next key without a rotation block and
/// the rotation block without a next key must both be rejected at argument parsing.
#[test]
fn rotation_flags_require_each_other() {
let mut next_only = start_args();
next_only.extend(["--encryption-key.next.hex", NEXT_KEY_HEX]);
assert!(ValidatorCommand::try_parse_from(next_only).is_err());

let mut block_only = start_args();
block_only.extend(["--encryption-key.next.rotation-block", "100"]);
assert!(ValidatorCommand::try_parse_from(block_only).is_err());

let mut both = start_args();
both.extend([
"--encryption-key.next.hex",
NEXT_KEY_HEX,
"--encryption-key.next.rotation-block",
"100",
]);
assert!(ValidatorCommand::try_parse_from(both).is_ok());

assert!(ValidatorCommand::try_parse_from(start_args()).is_ok());
}

/// A scheduled rotation yields a decrypter announcing the next key at the rotation block.
#[tokio::test]
async fn build_decrypter_schedules_rotation() {
let decrypter =
build_decrypter(INSECURE_ENCRYPTION_KEY_HEX, Some(NEXT_KEY_HEX), Some(42)).unwrap();
let info = decrypter.encryption_key().await.unwrap();
let next = info.next_key.expect("rotation must be scheduled");
assert_eq!(next.rotation_block_num, 42);

let plain = build_decrypter(INSECURE_ENCRYPTION_KEY_HEX, None, None).unwrap();
assert!(plain.encryption_key().await.unwrap().next_key.is_none());
}

/// Invalid rotation configurations must be rejected: a next key equal to the current one,
/// undecodable hex, key material of the wrong width, and a missing rotation block.
#[test]
fn build_decrypter_rejects_invalid_rotation_config() {
let same_key = build_decrypter(
INSECURE_ENCRYPTION_KEY_HEX,
Some(INSECURE_ENCRYPTION_KEY_HEX),
Some(42),
);
assert!(same_key.err().unwrap().to_string().contains("must differ"));

let bad_hex = build_decrypter(INSECURE_ENCRYPTION_KEY_HEX, Some("not hex"), Some(42));
assert!(bad_hex.err().unwrap().to_string().contains("decode"));

let short_key = build_decrypter(INSECURE_ENCRYPTION_KEY_HEX, Some("0badf00d"), Some(42));
assert!(short_key.is_err());

let missing_block = build_decrypter(INSECURE_ENCRYPTION_KEY_HEX, Some(NEXT_KEY_HEX), None);
assert!(missing_block.err().unwrap().to_string().contains("rotation block"));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@ use crate::COMPONENT;
#[tonic::async_trait]
impl grpc::server::validator_api::GetTransactionEncryptionKey for ValidatorService {
type Input = ();
type Output = grpc::transaction::TransactionEncryptionKey;
type Output = grpc::transaction::TransactionEncryptionKeyResponse;

fn decode(request: ()) -> tonic::Result<Self::Input> {
Ok(request)
}

fn encode(output: Self::Output) -> tonic::Result<grpc::transaction::TransactionEncryptionKey> {
fn encode(
output: Self::Output,
) -> tonic::Result<grpc::transaction::TransactionEncryptionKeyResponse> {
Ok(output)
}

Expand All @@ -30,22 +32,40 @@ impl grpc::server::validator_api::GetTransactionEncryptionKey for ValidatorServi
_metadata: &tonic::metadata::MetadataMap,
_extensions: &tonic::codegen::http::Extensions,
) -> tonic::Result<Self::Output> {
// Built entirely from state fixed at construction, so the endpoint stays available while a
// backup subscription holds the serve lock.
Ok(grpc::transaction::TransactionEncryptionKey {
scheme: i32::try_from(self.encryption_key_info.scheme)
.expect("scheme identifier must fit in i32"),
key_id: self.encryption_key_info.key_id.clone(),
public_key: self.encryption_key_info.public_key.clone(),
attestations: vec![grpc::transaction::ValidatorKeyAttestation {
validator_public_key: self.signer.public_key().to_bytes(),
signature: self.encryption_key_attestation.to_bytes(),
}],
next_key: self.encryption_key_info.next_key.as_ref().map(|next| {
// Built entirely from state fixed at construction (selected by the in-memory chain tip), so
// the endpoint stays available while a backup subscription holds the serve lock.
let attested = self.effective_encryption_key();
let validator_public_key = self.signer.public_key().to_bytes();
let key_message = |scheme: u32, key_id: &[u8], public_key: &[u8], signature: &[u8]| {
grpc::transaction::TransactionEncryptionKey {
scheme: i32::try_from(scheme).expect("scheme identifier must fit in i32"),
key_id: key_id.to_vec(),
public_key: public_key.to_vec(),
attestations: vec![grpc::transaction::ValidatorKeyAttestation {
validator_public_key: validator_public_key.clone(),
signature: signature.to_vec(),
}],
}
};
Ok(grpc::transaction::TransactionEncryptionKeyResponse {
current_key: Some(key_message(
attested.info.scheme,
&attested.info.key_id,
&attested.info.public_key,
&attested.attestation.to_bytes(),
)),
next_key: attested.info.next_key.as_ref().map(|next| {
let next_attestation = attested
.next_attestation
.as_ref()
.expect("a scheduled next key must carry its attestation");
grpc::transaction::NextTransactionEncryptionKey {
scheme: i32::try_from(next.scheme).expect("scheme identifier must fit in i32"),
key_id: next.key_id.clone(),
public_key: next.public_key.clone(),
key: Some(key_message(
next.scheme,
&next.key_id,
&next.public_key,
&next_attestation.to_bytes(),
)),
rotation_block_num: next.rotation_block_num,
}
}),
Expand Down
Loading
Loading