Skip to content

refactor: enforce tx input encryption on submission - #2381

Closed
juan518munoz wants to merge 1 commit into
nextfrom
jmunoz-force-input-encryption
Closed

refactor: enforce tx input encryption on submission#2381
juan518munoz wants to merge 1 commit into
nextfrom
jmunoz-force-input-encryption

Conversation

@juan518munoz

Copy link
Copy Markdown
Collaborator

Summary

This PR makes sealing mandatory. New flow now implies clients calling GetTransactionEncryptionKey before submission to seal inputs.

Plain transaction_inputs is gone from both submission messages, replaced by:

message ProvenTransaction {
  bytes transaction = 1;
  SealedTransactionInputs sealed_transaction_inputs = 2;
}

message SealedTransactionInputs {
  bytes key_id = 1;      // which key this was sealed to
  bytes ciphertext = 2;  // serialized SealedMessage
}

message TransactionBatch {
  bytes batch_proof = 1;
  optional bytes proposed_batch = 2;
  repeated SealedTransactionInputs sealed_transaction_inputs = 3;
}

Changelog

[[entry]]
scope = "protocol"
impact = "breaking"
description = "Transaction submissions now carry their private inputs sealed. `ProvenTransaction.transaction_inputs` and `TransactionBatch.transaction_inputs` are replaced by `sealed_transaction_inputs`, a `SealedTransactionInputs` envelope holding the encryption key id and the ciphertext."

[[entry]]
scope = "rpc"
impact = "breaking"
description = "`SubmitProvenTx` and `SubmitProvenTxBatch` reject submissions whose transaction inputs are not sealed against the validator's transaction encryption key. Fetching that key with `GetTransactionEncryptionKey` is now a required first step before submitting."

[[entry]]
scope = "rpc"
impact = "fixed"
description = "The transaction and batch submission handlers no longer log the full request, which was writing clients' private transaction inputs into logs and traces."

[[entry]]
scope = "rpc"
impact = "changed"
description = "`GetTransactionEncryptionKey` responses are cached briefly, since every submitting client must now fetch the key and a validator's answer is fixed for its lifetime."

[[entry]]
scope = "validator"
impact = "changed"
description = "Submitted transaction inputs are unsealed before validation, binding each ciphertext to one encryption key, one network and one transaction."

[[entry]]
scope = "ntx-builder"
impact = "changed"
description = "Network transactions are submitted with sealed transaction inputs, and the cached encryption key is refreshed automatically if the validator reports an unknown key."

[[entry]]
scope = "network-monitor"
impact = "changed"
description = "Counter increments and account deployments are submitted with sealed transaction inputs."

@igamigo
igamigo requested a review from SantiagoPittella July 27, 2026 19:17

@igamigo igamigo left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving a couple of comments for now but the overall shape looks good

Comment on lines +92 to +95
pub async fn create_genesis_aware_rpc_client_with_commitment(
rpc_url: &Url,
timeout: Duration,
) -> Result<(RpcClient, Word)> {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this not just replace the previous function?

Comment on lines +53 to +56
let (pool, genesis_commitment) =
create_genesis_aware_rpc_client_pool(&rpc_url, Duration::from_secs(30), connections)
.await
.expect("failed to create RPC client pool");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of the genesis commitment, I believe this could also just return the TransactionInputsSealer

/// Holding one avoids re-fetching the key per submission; callers should discard it when the
/// validator reports an unknown key id, which is how a key change is detected.
#[derive(Debug, Clone)]
pub struct TransactionInputSealer {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I'd name this TransactionInputsSealer instead

Comment on lines +114 to +116
scheme: u32,
key_id: Vec<u8>,
sealing_key: SealingKey,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wonder if this should be its own domain type and then we could have an impl From<proto::transaction::TransactionEncryptionKey> for TransactionEncryptionKey

@SantiagoPittella SantiagoPittella left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking good

impl TransactionInputSealer {
/// Builds a sealer from a served encryption key and the genesis commitment of the network the
/// inputs will be submitted to.
pub fn new(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we use/verify attestation here?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should!

async fn call_submit_proven_transaction(
&self,
tx: &ProvenTransaction,
sealed: Option<proto::transaction::SealedTransactionInputs>,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we using the None-option somewhere? Now that it is enforced, could we ever need an Option here?

let original_accept_header = metadata.get(http::header::ACCEPT.as_str()).cloned();

tracing::trace!(target: LOG_TARGET, ?request);
tracing::trace!(target: LOG_TARGET, "Received transaction submission");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we add any extra information herE?

Comment on lines +330 to 341
let result = self.inner.clone().submit_proven_tx(request).await;

// A validator restarted with a different encryption key rejects with `failed_precondition`.
// Drop the cached key so the next attempt seals against the current one.
if let Err(status) = &result
&& status.code() == tonic::Code::FailedPrecondition
{
self.invalidate_sealer().await;
}
result?;

Ok(())

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
let result = self.inner.clone().submit_proven_tx(request).await;
// A validator restarted with a different encryption key rejects with `failed_precondition`.
// Drop the cached key so the next attempt seals against the current one.
if let Err(status) = &result
&& status.code() == tonic::Code::FailedPrecondition
{
self.invalidate_sealer().await;
}
result?;
Ok(())
match self.inner.clone().submit_proven_tx(request).await {
Ok(_) => Ok(()),
Err(status) => {
// A validator restarted with a different encryption key rejects with
// `failed_precondition`. Drop the cached key so the next attempt seals against the
// current one.
if status.code() == tonic::Code::FailedPrecondition {
self.invalidate_sealer().await;
}
Err(status)
},
}

@huitseeker huitseeker left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Main item: let's check the attestation in TransactionInputSealer::new.


let mut cache = self.encryption_key_cache.lock().await;
if let Some(cached) = cache.as_ref()
&& cached.fetched_at.elapsed() < ENCRYPTION_KEY_CACHE_TTL

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we drop this cache, or invalidate it when a forwarded submission returns FailedPrecondition? After a validator restarts with a new key, the submit response tells the client to re-fetch, but this branch serves the same stale key for up to 30 seconds. The ntx builder then seals with the old key again and burns another note attempt instead of recovering. A restart test that changes the upstream key would cover this path.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't these keys be very long lived, relatively speaking?

I imagine the clients will already be caching the key themselves, so imo we can simplify and just always forward the request.

// A validator restarted with a different encryption key rejects with `failed_precondition`.
// Drop the cached key so the next attempt seals against the current one.
if let Err(status) = &result
&& status.code() == tonic::Code::FailedPrecondition

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this retry once after invalidating the sealer? NtxContext::submit only retries statuses accepted by is_transient_status, and that function excludes FailedPrecondition. This error therefore escapes immediately, so the actor marks every note in the candidate failed before a later block can try the fresh key. Re-fetching and resealing once here would make the key-change path recover without penalizing the notes.

impl TransactionInputSealer {
/// Builds a sealer from a served encryption key and the genesis commitment of the network the
/// inputs will be submitted to.
pub fn new(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should!

[Service]
Type=exec
Environment="OTEL_SERVICE_NAME=miden-validator"
EnvironmentFile=-/etc/opt/miden-validator/env

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this added?

Comment on lines +37 to +44
// Opaque identifier of the encryption key the inputs were sealed against, copied from
// [TransactionEncryptionKey.key_id].
bytes key_id = 1;

// The transaction inputs sealed with [miden_protocol::crypto::ies::SealingKey], encoded using
// the [miden_protocol::crypto::utils::Serializable] implementation for
// [miden_protocol::crypto::ies::SealedMessage].
bytes ciphertext = 2;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question - will we only ever support a single key type? Or should this be an enum?

Comment on lines +19 to +20
nodes without one forward it to the upstream RPC source. Responses are cached briefly, since every submitting client
must fetch the key first and a validator's answer does not change while it is running.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should keys not have an expiry set? At the moment this expiry is simply forever, so I don't know we would cache things briefly only. I'll revisit this when I see the impl.

let original_accept_header = metadata.get(http::header::ACCEPT.as_str()).cloned();

tracing::trace!(target: LOG_TARGET, ?request);
tracing::trace!(target: LOG_TARGET, batch_size = request.sealed_transaction_inputs.len());

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
tracing::trace!(target: LOG_TARGET, batch_size = request.sealed_transaction_inputs.len());
tracing::trace!(target: LOG_TARGET, batch.size = request.sealed_transaction_inputs.len());


let mut cache = self.encryption_key_cache.lock().await;
if let Some(cached) = cache.as_ref()
&& cached.fetched_at.elapsed() < ENCRYPTION_KEY_CACHE_TTL

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't these keys be very long lived, relatively speaking?

I imagine the clients will already be caching the key themselves, so imo we can simplify and just always forward the request.

Comment on lines +77 to +81
/// Genesis commitment of the network being submitted to, bound into the associated data of
/// sealed transaction inputs.
genesis_commitment: Word,
/// Cached sealer for transaction inputs, fetched on first submission.
sealer: Arc<RwLock<Option<TransactionInputSealer>>>,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here. There is no reason this can't already be populated, or handled by the client.

Comment on lines +263 to +268
spawn_blocking_in_current_span(move || {
UnsealingKey::X25519XChaCha20Poly1305(secret_key)
.unseal_bytes_with_associated_data(message, &associated_data)
.context("AEAD authentication failed")
})
.await

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are these functions async?

Comment on lines 244 to 245
scheme: Self::scheme_id(),
key_id: self.key_id(),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should these not be an enum? We surely don't support an open-ended set of schemas?

Comment on lines +113 to +118
pub struct TransactionInputSealer {
scheme: u32,
key_id: Vec<u8>,
sealing_key: SealingKey,
genesis_commitment: Word,
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we sure this shouldn't be an enum?

Comment on lines +59 to +64
pub fn transaction_inputs_associated_data(
scheme: u32,
key_id: &[u8],
genesis_commitment: Word,
tx_id: TransactionId,
) -> Vec<u8> {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be pub? Where else is it used? I assume that's why it isn't:

Suggested change
pub fn transaction_inputs_associated_data(
scheme: u32,
key_id: &[u8],
genesis_commitment: Word,
tx_id: TransactionId,
) -> Vec<u8> {
impl TransactionInputSealer {
fn build_associated_data(&self, tx_id: TransactionId) -> Vec<u8> {
...
}
}

@juan518munoz

Copy link
Copy Markdown
Collaborator Author

Closing, superseeded by #2389

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants