Skip to content

feat: add input encryption for validators#2342

Open
juan518munoz wants to merge 3 commits into
nextfrom
jmunoz-tx-input-encryption-p1
Open

feat: add input encryption for validators#2342
juan518munoz wants to merge 3 commits into
nextfrom
jmunoz-tx-input-encryption-p1

Conversation

@juan518munoz

@juan518munoz juan518munoz commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator

Closes #2319

Summary

Transaction inputs submitted to the network are currently visible to anyone in plaintext. First step to solve this is to provide validators with a shared encryption keypair, and make clients discover and use that key. Actual encryption of the submission path lands in a follow-up PR.

How:

  • Every validator is configured with the same shared transaction encryption key (Ed25519). It is passed via --encryption-key.hex / MIDEN_VALIDATOR_ENCRYPTION_KEY. If ommited, an insecure development default is used (that logs a loud warning at startup).
  • A new ValidatorEncryptor wraps the key and produces the attestation. Each validator signs a Poseidon2 commitment over domain_tag || scheme || key_id || genesis_commitment || public_key. The domain tag separates attestations from block header signatures, and the genesis commitment prevents cross-network replay.
  • There's a new GetTransactionEncryptionKey endpoint on the validator's internal API returns the public key, IES scheme id, key id, and attesting signature (TransactionEncryptionKey proto message). The public RPC exposes the same endpoint by forwarding to a validator and passing the response through unchanged, so clients can verify the attestation against a chain-recognized validator key without trusting the RPC.
  • ValidatorSigner::sign was generalized to sign_commitment so both block headers and key attestations go through the same local/KMS signing path.

Changelog

[[entry]]
scope       = "rpc"
impact      = "added"
description = "Added the `GetTransactionEncryptionKey` endpoint, returning the shared transaction encryption public key attested by a validator's signing key."

[[entry]]
scope       = "validator"
impact      = "added"
description = "Validators are now provisioned with a shared transaction encryption key via `--encryption-key.hex` / `MIDEN_VALIDATOR_ENCRYPTION_KEY` and serve it through the new `GetTransactionEncryptionKey` endpoint."

@sergerad
sergerad self-requested a review July 16, 2026 08:27
@SantiagoPittella
SantiagoPittella self-requested a review July 16, 2026 14:42
Comment thread bin/validator/src/server/validator_service/get_transaction_encryption_key.rs Outdated
Comment thread bin/validator/src/commands/mod.rs
Comment thread bin/validator/src/signers/mod.rs Outdated
Comment thread bin/validator/src/server/validator_service/tests.rs Outdated
Comment thread CHANGELOG.md Outdated
@juan518munoz
juan518munoz marked this pull request as ready for review July 17, 2026 12:13
@bobbinth
bobbinth self-requested a review July 17, 2026 14:50
@bobbinth

Copy link
Copy Markdown
Contributor

I'm planning to review this today.

@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.

Overall looks good for a Phase 1, thanks!

One key question is whether future validator code may assume that the decryption secret is available as bytes: a TEE stack may only expose operations. I made suggestions inline in the direction of providing a path that doesn't involve more implementation but is a bit more future-proof.

Another item of note is the public key response should not assume a fixed public key length or a fixed key id length. Phase 1 uses one X25519 key. Later we may use a DStack backed key, an HPKE KEM public key or a key whose identity is tied to an epoch. The key id may need to name a bunch of things. The wire type should therefore be opaque bytes, even if the Phase 1 implementation derives those bytes from the X25519 public key.

Finally, the current validator signature is useful, in that it proves that a chain recognized validator vouched for the key response. It does not prove that the key lives in a specific TEE image, which will come later. The response should have a place for that evidence, with field numbers reserved now to fit the future plan.

| `SubmitProvenTxBatch` | Submits an atomic batch of proven transactions and returns the node's current block height. |

`GetTransactionEncryptionKey` is forwarded to a validator and passed through unchanged: the public key is shared across
the whole validator set, while the attesting signature is specific to the serving validator. Clients verify the

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 add one sentence saying the attestation proves which validator vouched for the key, but does not prove freshness yet? After a key rotation, an RPC could replay an older signed key and the signature would still verify until we have a chain or epoch rule for freshness.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Added comment in cfe6987

Comment thread bin/validator/src/signers/mod.rs Outdated
Comment on lines +82 to +85
/// Constructs an encryptor from a locally provisioned shared secret.
pub fn new_local(secret_key: KeyExchangeKey) -> Self {
Self::Local(secret_key)
}

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.

I think the decrypt path should not require access to secret key bytes.

A local implementation may use bytes. A stricter TEE implementation may only expose a decrypt operation. The validator service should work with both. The API should not promise that secret bytes exist in the validator process.

I would keep the local key implementation, but put it behind an interface. A minimal version is enough.

#[async_trait]
pub trait TransactionInputDecryptor: Send + Sync {
    async fn encryption_key(&self) -> anyhow::Result<TransactionEncryptionKeyInfo>;

    async fn decrypt_transaction_inputs(
        &self,
        ciphertext: &[u8],
        associated_data: &[u8],
    ) -> anyhow::Result<Vec<u8>>;
}

pub struct TransactionEncryptionKeyInfo {
    pub scheme: u32,
    pub key_id: Vec<u8>,
    pub public_key: Vec<u8>,
    pub attestation_evidence: Vec<TransactionEncryptionKeyEvidence>,
}

Then rename the current concrete type to something like LocalX25519TransactionInputDecryptor. That local type can still hold KeyExchangeKey.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Added trait and renamed local implementation in cfe6987

/// Implements the gRPC API for the validator.
pub(crate) struct ValidatorService {
signer: ValidatorSigner,
encryptor: ValidatorEncryptor,

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.

This couples the service to the local key type. The service should store Arc<dyn TransactionInputDecryptor> or an equivalent generic wrapper. I think the service should only ask for key metadata and decrypt operations.

@juan518munoz juan518munoz Jul 17, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Decoupled in (marked as dead_code as it's unused until next iteration)

cfe6987

.map_err(ValidatorError::DatabaseError)?
.ok_or(ValidatorError::NoGenesisHeader)?
.commitment();
let encryption_key_attestation = signer

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.

Similarly I think this can just sign the key metadata returned by the decryptor, rather than enforcing a call to encryptor.attestation_commitment(...) on a concrete X25519 type, which seems a bit too specific and therefore brittle.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done in cfe6987

Comment on lines +35 to +40
debug!(target: LOG_TARGET, "Getting transaction encryption key");

let mut forwarded_request = Request::new(());
if let Some(accept) = original_accept_header {
forwarded_request.metadata_mut().insert(http::header::ACCEPT.as_str(), accept);
}

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.

I think the endpoint should not call ValidatorEncryptor::scheme_id() or self.encryptor.public_key().to_bytes() directly. It should return some TransactionEncryptionKeyInfo from the decryptor, plus the validator signature over the response fields.

This keeps the endpoint stable when the implementation switches later from a local X25519 key to a threshold public key.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Addressed in cfe6987

Comment on lines +35 to +39
Ok(grpc::transaction::TransactionEncryptionKey {
scheme: ValidatorEncryptor::scheme_id(),
key_id: self.encryptor.key_id(),
public_key: self.encryptor.public_key().to_bytes(),
signature: self.encryption_key_attestation.to_bytes(),

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.

The endpoint should not call ValidatorEncryptor::scheme_id() or self.encryptor.public_key().to_bytes() directly. It should return the TransactionEncryptionKeyInfo from the decryptor, plus the validator signature over the response fields ( see https://github.com/0xMiden/node/pull/2342/changes#r3604371368 ).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Tackled in cfe6987

Comment thread bin/validator/src/signers/mod.rs Outdated
Comment on lines +132 to +151
pub fn attestation_commitment_of(
scheme: u32,
key_id: u32,
genesis_commitment: Word,
public_key: &[u8],
) -> Word {
let genesis_commitment = genesis_commitment.to_bytes();
let mut payload = Vec::with_capacity(
Self::ATTESTATION_DOMAIN.len()
+ 2 * size_of::<u32>()
+ genesis_commitment.len()
+ public_key.len(),
);
payload.extend_from_slice(Self::ATTESTATION_DOMAIN);
payload.extend_from_slice(&scheme.to_le_bytes());
payload.extend_from_slice(&key_id.to_le_bytes());
payload.extend_from_slice(&genesis_commitment);
payload.extend_from_slice(public_key);
miden_protocol::Hasher::hash(&payload)
}

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.

This repeats the fixed32 assumption (see https://github.com/0xMiden/node/pull/2342/changes#r3604418954
) in Rust. It also commits to an ad hoc byte layout. If the proto changes to bytes key_id, this helper should take key_id: &[u8] and use an explicit transcript or length prefixed encoding for every variable length field.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Byte lenght in now prefixed as of cfe6987

// Encoded using [miden_serde_utils::Serializable] implementation for
// [miden_protocol::crypto::dsa::ecdsa_k256_keccak::Signature]. Verifiable against the
// validator's signing key committed in block headers.
bytes signature = 4;

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.

I like the signature, because it binds a chain-recognized validator to the key. It is not the same as TEE evidence. A future TEE deployment may need to return a quote, signature chain, compose hash, app measurement, epoch, or accepted measurement set.

I would reserve a few field numbers there, to document where that attestation will go.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Reserved field 5 to 9 for future implementations.

cfe6987

Comment on lines +105 to +108
/// Returns the sealing key that clients use to encrypt messages to the validator set.
pub fn sealing_key(&self) -> SealingKey {
SealingKey::X25519XChaCha20Poly1305(self.public_key())
}

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.

This helper is useful in tests, but we should be aware its type will change (in particular, it's unlikely to remain a client-facing abstraction). I would keep it #[cfg(test)] otherwise e.g. a client implementation will couple itself to X25519 before HPKE or threshold committee encryption lands.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Gated behind the test flag as of cfe6987

Comment thread bin/validator/src/signers/mod.rs Outdated
Comment on lines +154 to +164
pub fn unseal_bytes_with_associated_data(
&self,
message: SealedMessage,
associated_data: &[u8],
) -> Result<Vec<u8>, IesError> {
match self {
Self::Local(key) => UnsealingKey::X25519XChaCha20Poly1305(key.clone())
.unseal_bytes_with_associated_data(message, associated_data),
}
}
}

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.

I like that the raw associated data is explicit. We're working on a decryption context that uses both associated data and decryption context later, so a future PR may change the shape of this later.

@huitseeker
huitseeker requested a review from adr1anh July 17, 2026 20:41
address,
grpc_options,
signer,
decryptor,

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: spelling decrypter

shared transaction encryption keypair, an Ed25519 key that miden-crypto uses for X25519 key
agreement in its IES scheme. Clients will use it to encrypt the private transaction inputs they
submit, so that any validator in the set can decrypt them (submission-path encryption lands in a
follow-up change).

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.

(submission-path encryption lands in a
follow-up change)

Probably not necessary to add that line to the docs.

@bobbinth bobbinth 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.

Looks good! Thank you! I left some comments inline. Overall, I think this lays a good foundation, but there are two things that we'll need to add:

  1. Make provisions for key rotations. Specifically, if key rotation is upcoming, the endpoint should send the next encryption key - this way, the clients would have the next key ahead of time.
  2. In the RPC, instead of forwarding the request to the validator, we should be caching the encryption key info, and periodically refresh it.

Both of these could be done in follow-up PRs though, the first point could probably be done here as well (I wouldn't try to do the second point in this PR because it would be quite a bit of extra work that has no impact on the user-facing interface).

Comment thread CHANGELOG.md
## 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)).

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.

Why did we need to remove the newline here?

Also, seems like we don't have a changelog entry for this PR.

Comment thread proto/proto/rpc.proto
Comment on lines +46 to +52
// Returns the shared transaction encryption public key, attested by the signing key of the
// validator that served the request.
//
// The request is forwarded to a validator and the response is passed through unchanged. The
// encryption key is shared across the whole validator set, while the attesting signature is
// specific to the serving validator.
rpc GetTransactionEncryptionKey(google.protobuf.Empty) returns (transaction.TransactionEncryptionKey) {}

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.

"Returns the shared transaction encryption public key, attested by the signing key of the validator that served the request." - this sentence is not super clear. What does "shared" mean in this context? Shouldn't the key be signed by all validators?

Also, it may be good to expand on what the encryption key is used for.

Comment on lines +65 to +71
// The shared transaction encryption key, attested by a validator.
//
// The public key is shared across the whole validator set, while the attesting signature is
// specific to the validator that served the request. Verifying the signature against a
// chain-recognized validator signing key proves the encryption key was vouched for by a
// legitimate validator.
message TransactionEncryptionKey {

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.

The key should be signed by all validators.

Also, I think we are missing the next key fields. Basically, if a key is to be rotated at some point in the future, we need to send the new key. Basically, the response should contain the following pieces of information:

  • The current encryption key (what we have now).
  • Optional next encryption key - if a key is to be roated.
  • Optional block number at which the key is to be rotated.

The last two could potentially be grouped together into a single message.

Comment on lines +72 to +75
// IES scheme identifier as defined by [miden_protocol::crypto::ies::IesScheme].
//
// Currently always `1` (X25519 + XChaCha20-Poly1305).
uint32 scheme = 1;

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.

Should this be an enum?

Comment on lines +77 to +78
// Opaque identifier of the current encryption key. Changes when the key rotates.
bytes key_id = 2;

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.

Why is this needed?

Comment on lines +41 to +47
`GetTransactionEncryptionKey` is forwarded to a validator and passed through unchanged: the public key is shared across
the whole validator set, while the attesting signature is specific to the serving validator. Clients verify the
signature against a validator signing key they already trust from the chain and reconstruct the encryption key with
miden-crypto. The exact attestation payload is documented on the `TransactionEncryptionKey` proto message. Note that
this scheme does not hide transaction inputs from holders of the shared encryption secret (currently the network
operator and every validator) and provides no forward secrecy. 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

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.

The fact that we are forwarding the encryption key to the validator is an internal implementation detail which I don't think needs to be shared here. As mentioned in one of the other comments, the RPC should actually get the encryption key on startup and then refresh it periodically - instead of forwarding all requests to validators.

Comment on lines +221 to +222
#[cfg(test)]
mod tests {

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.

nit: can we add a section separator above this line?

Comment on lines 120 to 131
validator_key: String,

/// Key ID for the KMS key used by validator to sign blocks.
///
/// Cannot be used with `key.hex`.
#[arg(
long = "key.kms-id",
env = ENV_KMS_KEY_ID,
value_name = "VALIDATOR_KMS_KEY_ID",
group = "key"
)]
kms_key_id: Option<String>,

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.

Should we change these to signing_key and signing_key_kms_id?

Comment on lines +139 to +145
#[arg(
long = "encryption-key.hex",
env = ENV_ENCRYPTION_KEY,
value_name = "VALIDATOR_ENCRYPTION_KEY",
default_value = INSECURE_ENCRYPTION_KEY_HEX
)]
encryption_key: String,

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.

Not for this PR, but should we also provide an option to get the key from KMS?

Comment on lines +103 to +104
/// Opaque identifier of the current encryption key.
pub key_id: Vec<u8>,

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.

Why do we need this key_id?

.get_transaction_encryption_key(forwarded_request)
.await
.map(tonic::Response::into_inner),
RpcMode::FullNode { source_rpc, validator: None, .. } => source_rpc

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.

Would add explainer comments as to the difference between the two full node branches (given we don't have separate enums/modes for that).

async fn decrypt_transaction_inputs(
&self,
ciphertext: &[u8],
associated_data: &[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.

Would it make sense to create a type for the associated data we expect here? If not in this PR then subsequent ones?

@sergerad sergerad 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.

LGTM just wondering about whether its worth adding a type for associated_data in the trait. Also the decryptor->decrypter rename 🙏

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.

Transaction input encryption — simple validator-key scheme (Phase 1)

5 participants