Skip to content

feat: add input encryption for validators#2342

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

feat: add input encryption for validators#2342
juan518munoz wants to merge 2 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.

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.

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

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

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.

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

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.

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

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.

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.

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)

4 participants